/* global.css */
/* ─── Color tokens — Dark theme (default) ─── */
:root {
  --color-bg:               rgb(19, 21, 21);
  --color-card:             rgb(28, 31, 33);
  --color-card-lit:         rgb(33, 36, 39);
  --card-hairline:          rgba(255, 255, 255, 0.10);
  --color-text:             rgb(253, 251, 242);
  --color-body:             rgb(253, 251, 242);
  --color-muted:            rgb(187, 183, 170);
  --color-nav-blur:         rgba(77, 81, 81, 0.45);
  --color-border:           rgba(253, 251, 242, 0.1);
  --color-story-hover:      #dc2f02;
  --color-accent:           rgb(16, 27, 84);    /* metadata card blue, live site */
  --color-on-accent:        rgb(253, 251, 242); /* text on --color-accent, theme-invariant */
  --color-on-accent-muted:  rgb(196, 199, 224); /* muted labels on --color-accent */
  --color-nav-inactive:     rgb(25, 26, 26);
  --color-nav-active-bg:    rgb(253, 251, 242);
  --color-nav-active-text:  rgb(19, 21, 21);
  --color-separator:        #111213;
  --color-divider:          rgb(19, 21, 21);
  --color-separator-shadow: #ffffff14;
  --color-arrow-bg:         rgba(253, 251, 242, 0.08);
  --color-nav-text:         rgb(187, 183, 170);

  /* Card surface, exact from the live Framer site: a radial highlight
     lit from the top-left plus a 1px hairline along the top edge. That
     pairing — not a drop shadow — is what gave the cards their slight
     dome. The port had flattened it to the gradient's darker end stop. */
  /* Footer, exact from the live site: a navy that stays navy in both
     themes, like --color-accent. Only the top corners are rounded — the
     bar runs flush to the bottom of the page. */
  --color-footer:    linear-gradient(101deg, rgb(34, 53, 83) 0%, rgb(27, 45, 75) 100%);
  --footer-hairline: rgba(255, 255, 255, 0.15);

  --card-surface: radial-gradient(71.3% 61.9% at 28.8% 0%,
                    var(--color-card-lit) 0%, var(--color-card) 100%);

  --font-display: 'Fraunces', Georgia, serif;
  --font-sans:    'Urbanist', 'Inter', sans-serif;
  --font-body:    'Inter', sans-serif;

  /* Fluid type scale — hits the exact Framer desktop sizes at 1440px,
     shrinks smoothly down to a readable floor at small viewports. */
  /* Raised from clamp(3rem, 8vw + 0.5rem, 7.125rem) — 114px, Framer's
     value for a 500px column. The 1280px frame gives the heading a 580px
     column, so 114px left it small in the space. 10vw reaches the 144px
     ceiling at about 1440px wide; "Stories/" is the long line and measures
     418px there, well inside the column. */
  --text-h1:   clamp(3rem,    10vw, 9rem);  /* up to 144px */
  --text-h2:   clamp(2.5rem,  6vw + 0.5rem, 5.5rem);    /* was 88px  */
  --text-h4:   clamp(2rem,    4vw + 0.5rem, 3.75rem);   /* was 60px  */
  --text-body: clamp(1.125rem, 0.6vw + 1rem, 1.5rem);   /* was 24px  */
  --text-body2: 18px;   /* /Body 2          – Inter */
  --text-body4: 16px;   /* /Body 4          – Inter */
  --text-cap:   16px;   /* /Caption         – Urbanist medium, uppercase */

  /* The frame is 1280px wide including its own 40px gutters, so the
     widest the content ever runs is 1200px. Framer's was a flat 1000px
     with no gutters. */
  --max-width:    1280px;
  --pad-page:     40px;
  /* The hero artwork's width. The right column and the right rule both
     track it, so the artwork, the rule under it and the paragraph under
     that share one left edge. */
  --art-width:    488px;
  --art-gap:      40px;

  --radius-card:  40px;
  --radius-tile:  16px;
  /* The article measure. 828px is 46em at the body's 18px — the width the
     running text was already capped to. The whole detail page now uses it,
     so the title, the artwork and the prose share one edge. */
  --measure:      828px;
  --radius-pill:  100px;
  --radius-nav-bg: 30px;

  --nav-height:   96px;
  --nav-pill-w:   757px;
  --nav-top:      32px;
}

/* ─── Theme: Dark (explicit — mirrors :root) ─── */
[data-theme="dark"] {
  --color-bg:               rgb(19, 21, 21);
  --color-card:             rgb(28, 31, 33);
  --color-card-lit:         rgb(33, 36, 39);
  --card-hairline:          rgba(255, 255, 255, 0.10);
  --color-text:             rgb(253, 251, 242);
  --color-body:             rgb(253, 251, 242);
  --color-muted:            rgb(187, 183, 170);
  --color-nav-blur:         rgba(77, 81, 81, 0.45);
  --color-border:           rgba(253, 251, 242, 0.1);
  --color-story-hover:      #dc2f02;
  --color-accent:           rgb(16, 27, 84);    /* metadata card blue, live site */
  --color-on-accent:        rgb(253, 251, 242); /* text on --color-accent, theme-invariant */
  --color-on-accent-muted:  rgb(196, 199, 224); /* muted labels on --color-accent */
  --color-nav-inactive:     rgb(25, 26, 26);
  --color-nav-active-bg:    rgb(253, 251, 242);
  --color-nav-active-text:  rgb(19, 21, 21);
  --color-separator:        #111213;
  --color-divider:          rgb(19, 21, 21);
  --color-separator-shadow: #ffffff14;
  --color-arrow-bg:         rgba(253, 251, 242, 0.08);
  /* Inactive nav pill text. Same as --color-muted here — the dark-theme
     pill background is solid enough that --color-muted already clears
     4.5:1 against it (8.69:1, measured with axe-core). See the light
     theme below for why this needs its own token. */
  --color-nav-text:         rgb(187, 183, 170);
}

/* ─── Theme: Light (Warm Light) ─── */
[data-theme="light"] {
  --color-bg:               rgb(224, 219, 204);
  --color-card:             rgb(212, 207, 191);
  --color-card-lit:         rgb(224, 220, 206);
  --card-hairline:          rgba(255, 255, 255, 0.55);
  /* Not the dark theme's near-black inverted. On a warm cream ground
     rgb(19,21,21) measured 13.24:1, which reads as harsh rather than
     crisp; this is 11.63:1 — still well past AAA's 7:1, with the glare
     off it. */
  --color-text:             rgb(32, 33, 36);
  /* Running text one step lighter than the headings and the chrome:
     8.87:1 on the page, 7.88:1 on a card, so it still clears AAA on both
     while leaving a legible gap above --color-muted at 5.37:1. */
  --color-body:             rgb(51, 53, 58);
  --color-muted:            rgb(90, 85, 71);
  --color-nav-blur:         rgba(170, 165, 150, 0.55);
  --color-border:           rgba(32, 33, 36, 0.12);
  --color-story-hover:      #dc2f02;
  --color-accent:           rgb(16, 27, 84);    /* metadata card blue, live site */
  --color-on-accent:        rgb(253, 251, 242); /* text on --color-accent, theme-invariant */
  --color-on-accent-muted:  rgb(196, 199, 224); /* muted labels on --color-accent */
  /* task-20 (nav-blur alpha change): rgba(19,21,21,0.1) — a nearly-
     transparent dark tint — let whatever scrolled beneath the nav
     (article hero images, the navy story-meta-card, etc.) dominate the
     inactive pill's actual background. --color-nav-text held up against
     an empty/neutral backdrop (5.79:1) but measured only 2.4:1 with
     axe-core once a saturated background (the navy --color-accent
     story-meta-card) scrolled beneath — true at every --color-nav-blur
     alpha tested, 0.45 through the original 0.82, so lowering the blur
     alpha wasn't the cause. A light, mostly-opaque card-tone overlay
     gives the pill its own stable backing regardless of what's behind;
     5.46:1 measured with axe-core against that same navy backdrop, and
     the nav-blur tint still shows through the remaining ~50% and in the
     un-pilled gaps around the icons. */
  --color-nav-inactive:     rgba(212, 207, 191, 0.5);
  --color-nav-active-bg:    rgb(32, 33, 36);
  --color-nav-active-text:  rgb(253, 251, 242);
  --color-separator:        rgba(32, 33, 36, 0.18);
  --color-divider:          rgba(32, 33, 36, 0.12);
  --color-separator-shadow: transparent;
  --color-arrow-bg:         rgba(32, 33, 36, 0.08);
  /* task-20: --color-muted (rgb(90,85,71)) on the composited nav-blur +
     nav-inactive background measured 2.83:1 with axe-core — the
     translucent light-theme nav pill is materially darker than
     --color-card, which is what --color-muted was tuned against. A
     dedicated, darker token for just the inactive nav pill text clears
     4.5:1 with margin (5.79:1 against the measured composited
     background) without touching --color-muted anywhere else it's used. */
  --color-nav-text:         rgb(40, 38, 30);
}

/* ─── Theme transitions ─── */
body,
.nav-blur,
.nav-item,
.recent-card,
.footer-card,
.story-item + .story-item::before,
.separator,
.story-arrow {
  transition:
    background-color 0.4s ease,
    color            0.4s ease,
    border-color     0.4s ease,
    box-shadow       0.4s ease;
}

/* ─── Light grain texture ─── */
[data-theme="light"] body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 9998;
  pointer-events: none;
  opacity: 0.055;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
  background-repeat: repeat;
  background-size: 180px;
}

/* ─── Screen-reader-only utility ─── */
.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;
}

/* ─── Visible focus ───
   var(--color-text) is near-white on dark backgrounds and near-black on
   light backgrounds (see theme tokens above), so this one rule carries
   adequate contrast against --color-bg/--color-card in both themes. */
:focus-visible {
  outline: 2px solid var(--color-text);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ─── System-preference default (no explicit choice saved) ─── */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --color-bg:               rgb(224, 219, 204);
    --color-card:             rgb(212, 207, 191);
    --color-card-lit:         rgb(224, 220, 206);
    --card-hairline:          rgba(255, 255, 255, 0.55);
    /* Matches [data-theme="light"] above — see the note there. */
    --color-text:             rgb(32, 33, 36);
    --color-body:             rgb(51, 53, 58);
    --color-muted:            rgb(90, 85, 71);
    --color-nav-blur:         rgba(170, 165, 150, 0.55);
    --color-border:           rgba(32, 33, 36, 0.12);
    --color-story-hover:      #dc2f02;
  --color-accent:           rgb(16, 27, 84);    /* metadata card blue, live site */
  --color-on-accent:        rgb(253, 251, 242); /* text on --color-accent, theme-invariant */
  --color-on-accent-muted:  rgb(196, 199, 224); /* muted labels on --color-accent */
    --color-nav-inactive:     rgba(212, 207, 191, 0.5);
    --color-nav-active-bg:    rgb(32, 33, 36);
    --color-nav-active-text:  rgb(253, 251, 242);
    --color-separator:        rgba(32, 33, 36, 0.18);
    --color-divider:          rgba(32, 33, 36, 0.12);
    --color-separator-shadow: transparent;
    --color-arrow-bg:         rgba(32, 33, 36, 0.08);
    --color-nav-text:         rgb(40, 38, 30);
  }
}

/* ─── Theme toggle ─── */
/* Was a trigger plus a listbox with two preview thumbnails. Deleted: two
   themes do not need a menu, and the menu opened downward from a bar that
   is fixed to the top on desktop and to the bottom below 640 — where it
   rendered off-viewport and could not be reached at all. The `.tp-*`
   preview swatches went with it. */
.theme-switcher {
  display: flex;
  align-items: center;
}

.theme-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  /* position: relative for the same reason .nav-item has it — .nav-blur is
     absolutely positioned with a background and a 12px backdrop-filter
     across the whole bar, so it paints above every statically-positioned
     sibling. Below 640, where this button sits in the flow rather than in
     an absolutely-positioned .nav-social, the glyph was rendering behind
     that panel: present, correct colour, 18px, and invisible. It still
     worked, because the blur has pointer-events: none — hit-testing
     ignored it while painting did not. */
  position: relative;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: var(--color-muted);
  cursor: pointer;
  padding: 0;
  transition: color 0.2s ease;
}
@media (hover: hover) and (pointer: fine) {
  .theme-toggle:hover { color: var(--color-text); }
}

/* Both glyphs are in the markup; CSS picks one off the root attribute, so
   the correct icon is painted on the first frame — before theme.js runs.
   Doing it in script instead would flash the wrong icon on every load.
   Dark shows the moon, light shows the sun: the icon names where you are.
   The button's aria-label says where the click goes, which is the
   opposite; theme.js keeps that in step. */
.theme-icon { display: none; }
.theme-icon--moon { display: block; }

[data-theme="light"] .theme-icon--moon { display: none; }
[data-theme="light"] .theme-icon--sun  { display: block; }

/* System preference, no explicit choice saved — mirrors the two blocks
   above so a visitor whose OS is light also sees the sun. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .theme-icon--moon { display: none; }
  :root:not([data-theme="dark"]) .theme-icon--sun  { display: block; }
}

/* ─── Reset ─── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html {
  scroll-behavior: smooth;
  scroll-padding-top: 140px;
}
body {
  font-family: var(--font-body);
  background: var(--color-bg);
  color: var(--color-text);
  font-size: var(--text-body4);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
a:hover { text-decoration: none; }
ul { list-style: none; }
svg { display: block; }

/* ─── Nav outer frame: tracks --max-width, fixed, top: 32px ─── */
.site-nav-wrapper {
  position: fixed;
  top: 32px;
  left: 50%;
  transform: translateX(-50%);
  /* Same measure as .main's content box: --max-width minus its gutters,
     and the same gutters again when the viewport is the constraint. A nav
     on different gutters visibly overhangs the content edges. */
  width: min(calc(var(--max-width) - var(--pad-page) * 2),
             calc(100% - var(--pad-page) * 2));
  height: auto;
  min-height: 64px;
  z-index: 100;
}

/* Blur backdrop: absolute, 999px × 64px, centerY, rgba(77,81,81,0.45)/light rgba(170,165,150,0.55), borderRadius 30px */
.nav-blur {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background: var(--color-nav-blur);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-radius: 30px;
  box-shadow: rgba(0, 0, 0, 0.25) 0px 1px 1px 0px, inset 0 0 0 1px rgba(255, 255, 255, 0.1);
  overflow: hidden;
  pointer-events: none;
}

/* Nav pill: absolute, left: 8px, 757px wide, 96px tall, borderRadius 100px, transparent */
.nav-pill {
  position: absolute;
  left: 8px;
  top: 0;
  bottom: 0;
  width: 757px;
  max-width: calc(100% - 8px);
  border-radius: 100px;
  display: flex;
  align-items: center;
  gap: 22px;  /* exact gap from Framer */
  padding-left: 0;
}

/* Each item: 106×48px pill, borderRadius 30px */
/* Hidden until the pills drop their labels at 640. */
.nav-icon { display: none; }

.nav-item {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 106px;
  height: 48px;
  border-radius: 30px;
  font-family: var(--font-sans);
  font-size: 14px;
  font-weight: 700;  /* Urbanist-700 */
  white-space: nowrap;
  flex-shrink: 0;
  position: relative;
  text-decoration: none;
  transition: background 0.2s ease, color 0.2s ease;
  background: var(--color-nav-inactive);
  color: var(--color-nav-text);
}
.nav-item span {
  position: relative;
}
.nav-item span::after {
  content: '';
  position: absolute;
  bottom: -3px;
  left: 0;
  width: 100%;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  /* underline grows left-to-right; hover → ease; micro-interaction 150ms */
  transition: transform 0.15s ease;
}
@media (hover: hover) and (pointer: fine) {
  .nav-item:hover:not(.active) {
    color: var(--color-text);
  }
  .nav-item:hover:not(.active) span::after {
    transform: scaleX(1);
  }
}
.nav-item.active {
  background: var(--color-nav-active-bg);
  color: var(--color-nav-active-text);
}

/* Social icons: absolute, right-aligned in the 1000px frame */
/* Framer: left: 743px, width: 235px — icons right-aligned inside */
.nav-social {
  position: absolute;
  right: 0;
  top: 0;
  bottom: 0;
  width: 235px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 28px;
  padding-right: 22px;
}
.nav-social a {
  color: var(--color-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  min-height: 32px;
  transition: color 0.15s ease;
}
@media (hover: hover) and (pointer: fine) {
  .nav-social a:hover { color: var(--color-text); }
}

/* Hero sketch: drag to tilt. Perspective lives on the frame so the palette
   bar stays flat while the canvas rotates inside it. Same perspective value
   as the card entrance, so the site has one 3D vocabulary, not two. */
.hero-sketch {
  perspective: var(--anim-tilt-perspective, 5000px);
}
.hero-sketch .sketch-link {
  display: block;
  transform-style: preserve-3d;
  cursor: grab;
  touch-action: none;
  -webkit-user-drag: none;
  user-select: none;
}

.hero-sketch.is-tilting .sketch-link {
  cursor: grabbing;
}

/* ─── Main content ─── */
.main {
  max-width: var(--max-width);
  margin: 0 auto;
  /* top padding = nav-top + nav-height + gap */
  padding: calc(var(--nav-top) + var(--nav-height) + 80px) var(--pad-page) 80px;
}

/* ─── Hero (exact: 1000×510px frame, top: 208px in Framer canvas) ─── */
.hero {
  position: relative;
  width: 100%;
  height: 510px;
  overflow: hidden;
}

/* Logo+name: absolute bottom-left, 340×96px, gap: 24px (exact from Framer) */
.hero-identity {
  position: absolute;
  bottom: 20px;
  left: 0;
  width: 340px;
  height: 96px;
  display: flex;
  align-items: center;
  gap: 24px;
}

/* Logo circle: 96×96px, border-radius: 100px — SVG overflows and gets clipped */
.site-logo {
  width: 96px;
  height: 96px;
  border-radius: 100px;
  overflow: hidden;
  flex-shrink: 0;
  position: relative;
}

/* 1:1 with Framer: the artwork's own coordinates run 50.323..146.323, so
   the markup pairs a 96x96 viewBox with translate(-50.323, -50.322) and the
   svg simply fills the 96px circle. This used to be a 172px svg on a
   196.645 viewBox, absolutely centred — which drew the same art at 0.875x,
   thinning every stroke and cropping a slightly wider region than intended. */
.site-logo svg {
  display: block;
  width: 100%;
  height: 100%;
}
.site-logo svg path {
  stroke: var(--color-text);
  transition: stroke 0.4s ease;
}

/* Name: /SubTitle = Fraunces Regular, 28px, 1.3em */
.hero-name {
  font-family: var(--font-display);
  font-size: 28px;
  font-weight: 400;
  line-height: 1.3em;
  letter-spacing: 0px;
  color: var(--color-text);
  white-space: nowrap;
}

/* Sketch: 488×490px, right: 0, centerY≈48% (top≈0 in a 510px frame) */
.hero-sketch {
  position: absolute;
  right: 0;
  top: 0;
}

/* ─── Palette switcher ─── */
.palette-bar {
  position: absolute;
  bottom: 16px;
  right: 16px;
  display: flex;
  align-items: center;
  gap: 0;
  background: rgba(19, 21, 21, 0.8);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(253, 251, 242, 0.1);
  border-radius: 100px;
  padding: 7px 10px;
  z-index: 10;
  cursor: default;
}
.palette-icon { opacity: 0.4; flex-shrink: 0; display: block; }

.palette-swatches {
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 0;
  overflow: hidden;
  opacity: 0;
  /* on-screen morph → ease-in-out; standard UI 150-250ms */
  transition: max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, padding-left 0.25s ease-in-out;
  padding-left: 0;
}
@media (hover: hover) and (pointer: fine) {
  .palette-bar:hover .palette-swatches {
    max-width: 200px;
    opacity: 1;
    padding-left: 10px;
  }
}

.palette-divider { width: 1px; height: 12px; background: rgba(253, 251, 242, 0.12); flex-shrink: 0; }
.palette-swatch {
  display: flex;
  width: 36px;
  height: 14px;
  border-radius: 7px;
  overflow: hidden;
  border: 1.5px solid rgba(255, 255, 255, 0.15);
  cursor: pointer;
  padding: 0;
  background: none;
  /* hover → ease; micro-interaction 100-150ms */
  transition: transform 0.15s ease, border-color 0.15s ease;
  flex-shrink: 0;
}
.palette-swatch span { flex: 1; display: block; }
@media (hover: hover) and (pointer: fine) {
  .palette-swatch:hover { transform: scale(1.12); }
}
.palette-swatch.active { border-color: rgb(253, 251, 242); transform: scale(1.12); }

.sketch-link {
  display: block;
  border-radius: 40px;   /* exact from Framer VideoComponent */
  overflow: hidden;
  line-height: 0;
  /* larger element → slightly longer; hover → ease */
  transition: opacity 0.2s ease, transform 0.2s ease;
  will-change: transform;
}
@media (hover: hover) and (pointer: fine) {
  .sketch-link:hover { opacity: 0.88; transform: scale(0.985); }
}

#sketch-container {
  width: 488px;
  height: 490px;
  /* The 3D canvas is intentionally larger than the artwork so tilted,
     extruded geometry isn't clipped at the edges. It's centred on this box
     and allowed to overflow. */
  overflow: visible;
  display: block;
  position: relative;
}
#sketch-container canvas {
  display: block;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  /* The canvas must receive pointer events so the sketch can be dragged.
     Clicks still reach the wrapping OpenProcessing link by bubbling; the
     sketch suppresses that only when the pointer actually moved. */
  pointer-events: auto;
  touch-action: none;
  -webkit-user-drag: none;
  user-select: none;
}

/* ─── Separators — exact Framer styles ─── */
.separators {
  position: relative;
  width: 100%;
  height: 8px;
  margin-top: 44px;
  margin-bottom: 64px;
}

.separator {
  position: absolute;
  height: auto;
  border-radius: 10px;
  background-color: var(--color-separator);
  overflow: hidden;
  box-shadow: 0 1px var(--color-separator-shadow);
  top: 0;
  bottom: 4px;
}

/* Proportional to the 1000px desktop frame (426/510/490 → 42.6%/51%/49%)
   so the pair scales with the container instead of overflowing it below
   1000px viewports. */
/* The right rule sits exactly under the artwork; the left takes the rest. */
.separator-left  { left: 0; right: calc(var(--art-width) + var(--art-gap)); width: auto; }
.separator-right { right: 0; left: auto; width: var(--art-width); }

/* ─── Stories / Work (exact: Heading 1 = Fraunces 300, 114px) ─── */
.stories-section {
  width: 100%;
  display: grid;
  /* minmax(0, ...) not plain 1fr: a bare 1fr floors at the heading's
     min-content, so once the artwork column takes a fixed width the grid
     would push the page wider rather than let the heading shrink. */
  grid-template-columns: minmax(0, 1fr) var(--art-width);
  gap: var(--art-gap);
  align-items: start;
  /* 64px from "Work"'s baseline to the card, matching the 64px from the
     rule to "Stories/"'s cap-line above. The subtraction is the same
     line-height artefact as the heading's negative margin-top: the box
     bottom sits 0.056em below the baseline, so the margin has to be short
     by that much for the gap you see to come out at 64. Scaled off
     --text-h1 rather than a flat 56px so it tracks the fluid size. */
  margin-bottom: calc(64px - 0.056 * var(--text-h1));
}

.stories-heading {
  font-family: var(--font-display);
  font-size: var(--text-h1);
  font-weight: 300;
  /* Tight by intent, and it keeps the block inside its old height: two
     lines at 144px x 0.85 is 245px, against 274px for two lines at the
     old 114px x 1.2. Bigger letters, shorter section. */
  line-height: 0.85em;
  letter-spacing: 0px;
  color: var(--color-text);
}

/* The rule above and the cap-line of "Stories/" should sit the same
   distance apart as the rule and the logo above it (64px). They do not by
   default: with line-height below 1 the glyphs sit inside a shorter box,
   and Fraunces' cap-top lands 0.05em below the line box top. Pull the
   heading up by exactly that, in em so it tracks the fluid size. */
.stories-heading-block { align-self: last baseline; }
.stories-heading-block .stories-heading { margin-top: -0.05em; }
.stories-desc-block { padding-top: 16px; align-self: last baseline; }

.stories-desc {
  font-family: var(--font-body);
  /* Fixed, not var(--text-body). This column is a fixed 488px
     (--art-width), but --text-body is fluid (18->24px), so the measure
     changed with the viewport for no layout reason -- and at its 24px cap
     (>=1333px viewports) it broke badly, pulling three mid-lines in to
     82-87% of the column and stranding the "but" at a line end. A fixed
     column wants a fixed size; 23px is the one that fills this measure
     evenly (every line 95-98%). Goes fluid again below 1000px, where the
     section stacks and the column stops being fixed. */
  font-size: 23px;
  line-height: 1.5em;
  color: var(--color-muted);
  font-weight: 300;
  /* Orphan control for future copy edits; no effect on the text as it
     stands. Unsupported browsers just wrap normally. */
  text-wrap: pretty;
}

/* ─── Recent card (exact: borderRadius 40px, padding 32px, gap 48px) ─── */
.recent-card {
  background: var(--card-surface);
  border-radius: var(--radius-card);  /* 40px */
  padding: 32px;
  position: relative;
  display: flex;
  flex-direction: column;
  /* 16, not 48. The first row carries 32px of its own padding on top, so
     the real distance from the heading to the first title is this plus
     that — 48px was putting 80px of nothing between them and pushing the
     first article's title and byline below the fold. */
  gap: 16px;
}

.recent-heading {
  font-family: var(--font-display);
  font-size: var(--text-h4);  /* 60px — was --text-h2/88px, too close to the
     114px .stories-heading above it to read as a distinct hierarchy level
     (task-20). 60px matches .section-heading, the same "section heading
     inside a page" role used elsewhere (Experience/Contact/Processing/etc). */
  font-weight: 300;
  line-height: 1.3em;
  letter-spacing: 0px;
  color: var(--color-text);
}

/* Story list (gap 32px, padding 24px top/bottom per item) */
.story-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.story-item { position: relative; }
.story-item + .story-item::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 2px;
  /* Not --color-separator: that is the hero's 4px rules, which are a
     different element and a shade darker (rgb(17,18,19), 10px radius).
     The card dividers are rgb(19,21,21) with square ends. */
  background: var(--color-divider);
  box-shadow: 0 1px var(--color-separator-shadow);
}

.story-link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 32px 0;
  /* Enough for the 108px hover preview to sit inside the row with a little
     clearance, now that the preview no longer sets the height itself. */
  min-height: 128px;
}

.story-text {
  display: flex;
  flex-direction: column;
  gap: 4px;
  flex: 1;
  min-width: 0;
  transition: transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}

.story-title {
  font-family: var(--font-body);
  font-size: var(--text-body2);  /* 18px */
  font-weight: 300;
  line-height: 1.5em;
}

.story-desc {
  font-family: var(--font-body);
  font-size: var(--text-caption, 13px);
  font-weight: 400;
  line-height: 1.4em;
  /* Was `color: var(--color-text-muted); opacity: 0.6;` — --color-text-muted
     was never defined anywhere in this stylesheet, so this fell back to the
     inherited text color and the 0.6 opacity alone did the dimming. That
     measured 4.11:1 on the light theme's story-nav card (axe-core), below
     the 4.5:1 AA text threshold. --color-muted is the token already tuned
     to clear 4.5:1 against --color-card in both themes (task-20). */
  color: var(--color-muted);
}

/* Hover preview of the story's cover, in the gap the row already leaves
   between the text and the arrow. A fixed 104px box in normal flow, so
   nothing shifts when it appears and it can never overlap a long subtitle.
   Populated by recent-thumbs.js on first hover — empty until then. */
.story-thumb {
  display: none;         /* only hover-capable pointers ever see it */
  /* Taken out of the flow. In the flow this 108px box set the height of
     every row even while invisible — rows were 172px tall for 45px of
     text, and the last one left a 93px void above the bottom of the card.
     The row is sized by .story-link's min-height now; the preview is
     centred over it and costs no height at all.
     Anchored to .story-item (position: relative). right clears the 48px
     arrow plus the row's own 24px gap. A story sets --thumb-ar when its
     preview is not the default shape (the leads banner is 1800x720, and
     at the default 1.48 ratio cover would crop 40% off its ends). */
  position: absolute;
  top: 50%;
  right: 72px;
  --thumb-h: 108px;
  height: var(--thumb-h);
  width: calc(var(--thumb-h) * var(--thumb-ar, 1.481));
  border-radius: 6px;
  overflow: hidden;
  background: var(--color-bg);
  opacity: 0;
  transform: translate(10px, -50%);
  transition:
    opacity   0.28s cubic-bezier(0.25, 0, 0, 1),
    transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}
.story-thumb img,
.story-thumb video,
.story-thumb canvas {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.story-arrow {
  flex-shrink: 0;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--color-arrow-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text);
  transition: background 0.28s cubic-bezier(0.25, 0, 0, 1), transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}
.story-arrow svg {
  transition: transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}
@media (hover: hover) and (pointer: fine) {
  .story-link:hover .story-text {
    transform: translateX(6px);
  }
  /* The glyph goes to the on-accent cream, not --color-text: the disc is
     the orange CTA at this point, and --color-text is near-black in the
     light theme, which put a black arrow on orange. Same token the navy
     card uses for the same reason — it does not follow the theme. */
  .story-link:hover .story-arrow {
    background: var(--color-story-hover);
    color: var(--color-on-accent);
    transform: scale(1.1);
  }
  .story-link:hover .story-arrow svg {
    transform: rotate(45deg);
  }

  .story-thumb { display: block; }
  /* :focus-visible as well as :hover — the keyboard path to a row is focus,
     and recent-thumbs.js loads the image on either. */
  .story-link:hover .story-thumb,
  .story-link:focus-visible .story-thumb {
    opacity: 1;
    transform: translate(0, -50%);
  }
}

/* Top-edge hairline that completes the card's domed look. Drawn as an
   overlay rather than an inset box-shadow so it cannot be painted over by
   content that sits flush against the top edge. */
.recent-card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-top: 1px solid var(--card-hairline);
  border-radius: inherit;
  pointer-events: none;
}

/* ─── Stories index ─── */
/* Two blocks: Latest, one story image-left / text-right, and More, the rest
   in a uniform grid. Every grid card is the same width and the same height,
   so the covers are cropped to one shape (object-fit: cover) rather than
   each keeping its own — the covers run 0.96 to 2.05 and no shared box can
   honour that. No card container: the covers sit on the page background and
   the type below them is the only chrome. */
.stories-block { margin-bottom: 120px; }
.stories-block:last-child { margin-bottom: 0; }

/* Same as .recent-heading on the home page: --text-h4 at weight 300. These
   fill the same role — a section heading inside a page — so they should be
   the same size, not two guesses at it. */
.stories-block-heading {
  font-family: var(--font-display);
  font-size: var(--text-h4);
  font-weight: 300;
  line-height: 1.3em;
  letter-spacing: 0px;
  color: var(--color-text);
  margin: 0 0 32px;
}

/* Latest: image left, text right, the text starting level with the top of
   the image rather than floating in the middle of it. */
.story-feature-wrap { display: block; }
.story-feature {
  display: grid;
  /* minmax(0, ...), not a bare fr: the media column holds an <img>, whose
     automatic minimum size is content-based, so a plain 1.35fr can floor
     at the picture's intrinsic width and push the grid past the page. */
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: 48px;
  align-items: start;
  transition: transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}
.story-feature .story-tile-media { margin: 0; }
.story-feature-body { padding-top: 0; }
.story-feature .story-tile-title { font-size: 28px; }

/* More: equal columns, and stretch so every card in a row is the same
   height whatever its title wraps to. */
.story-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 56px 40px;
  align-items: stretch;
  list-style: none;
  padding: 0;
  margin: 0;
}

.story-tile { min-width: 0; }

/* Scoped to grid cards: .story-feature carries .story-tile-link too, and an
   unscoped `display: block` here would override its `display: grid` — same
   specificity, later in the file. */
.story-tile > .story-tile-link {
  display: block;
  height: 100%;
  transition: transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}

.story-tile-media {
  position: relative;
  width: 100%;
  overflow: hidden;
  border-radius: var(--radius-tile);
  background: var(--color-card);
  /* aspect-ratio is set inline, per section — one shape for the feature,
     one for the grid. */
}

.story-tile-cover {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* A live sketch laid over the cover. The cover stays underneath as the
   poster, so the tile is never empty while p5 loads and degrades to the
   still if the script never runs. */
.story-tile-sketch {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.4s ease;
}
.story-tile-sketch.is-live { opacity: 1; }
/* No still underneath: the box would otherwise be a blank card-coloured
   rectangle until the canvas arrives. Where a sketch paints its own ground,
   match it here so there is no flash of a different colour first. Not a
   red-orange: that is within a few points of --color-story-hover (#dc2f02),
   the arrow-hover CTA, and artwork should not read as a control. */
.story-tile-media:not(:has(.story-tile-cover)) { background: var(--color-bg); }
.story-tile-media:has([data-sketch="graph"]) { background: #0C0E0E; }
.story-tile-sketch canvas {
  display: block;
  width: 100% !important;
  height: 100% !important;
}

/* A tile with no cover still needs to occupy a sensible block. For the
   feature that means collapsing to one column — with no media, a two-column
   grid leaves the text stranded in the left half beside an empty right. */
.story-tile--no-cover .story-tile-body { padding-top: 0; }
.story-feature.story-tile--no-cover { grid-template-columns: 1fr; }
.story-feature.story-tile--no-cover .story-tile-subtitle { max-width: 46em; }

.story-tile-body { padding-top: 20px; }

.story-tile-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
}

.story-tile-title {
  font-family: var(--font-display);
  font-size: 20px;
  font-weight: 400;
  line-height: 1.3em;
  color: var(--color-text);
  margin: 0;
}

.story-tile-arrow {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--color-arrow-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text);
  align-self: flex-start;
  transition: background 0.28s cubic-bezier(0.25, 0, 0, 1), transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}
.story-tile-arrow svg {
  transition: transform 0.28s cubic-bezier(0.25, 0, 0, 1);
}

.story-tile-meta {
  font-family: var(--font-sans);
  font-size: var(--text-cap);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--color-muted);
  opacity: 0.7;
  margin: 8px 0 0;
}

.story-tile-subtitle {
  font-family: var(--font-body);
  font-size: var(--text-body4);  /* 16px */
  font-weight: 300;
  line-height: 1.5em;
  color: var(--color-muted);
  margin: 8px 0 0;
}

/* Topic badges. Quiet by design: they are context for a title, not
   controls — no fill, and the same muted colour as the subtitle. */
.story-tile-topics {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  list-style: none;
  padding: 0;
  margin: 12px 0 0;
}

.story-topic {
  font-family: var(--font-sans);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--color-muted);
  border: 1px solid var(--color-border);
  border-radius: 100px;
  padding: 4px 10px;
  white-space: nowrap;
}

@media (hover: hover) and (pointer: fine) {
  .story-tile-link:hover { transform: translateY(-4px); }
  .story-tile-link:hover .story-tile-arrow {
    background: var(--color-story-hover);
    color: var(--color-on-accent);
    transform: scale(1.1);
  }
  .story-tile-link:hover .story-tile-arrow svg { transform: rotate(45deg); }
}

@media (max-width: 1000px) {
  .story-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 700px) {
  .story-feature { grid-template-columns: 1fr; gap: 20px; }
  .story-feature .story-tile-title { font-size: 22px; }
  .story-grid { grid-template-columns: 1fr; gap: 48px; }
  .stories-block { margin-bottom: 80px; }
}

/* ─── Footer ─── */
.site-footer {
  max-width: var(--max-width);
  margin: 0 auto;
  padding: 0 var(--pad-page);
}

.footer-card {
  background: var(--color-footer);
  border-radius: var(--radius-card) var(--radius-card) 0 0;  /* 40px, top only */
  /* --footer-pad-x, not a flat 32px. The card is already inset from the
     viewport by --pad-page, so its own side padding stacks on top of that:
     32px inside a 16px gutter put the copyright 48px from the edge of a
     500px screen, while everything else on the page sat at 16 and the
     live Framer footer sits at 32. Tracking the same steps as
     .recent-card keeps the two cards on one rhythm. */
  --footer-pad-x: 32px;
  padding: 24px var(--footer-pad-x);
  min-height: 120px;   /* Framer's was 180px; shortened deliberately */
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.footer-card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-top: 1px solid var(--footer-hairline);
  border-radius: inherit;
  pointer-events: none;
}

/* Theme-invariant, like the navy they sit on: --color-muted would paint
   rgb(90,85,71) onto dark blue in the light theme. */
.footer-copy {
  font-family: var(--font-body);
  font-size: var(--text-body4);
  font-weight: 500;
  color: var(--color-on-accent);
}

.footer-social { display: flex; gap: 20px; }
.footer-social a {
  color: var(--color-on-accent);
  display: flex;
  align-items: center;
  transition: color 0.15s ease;
}
@media (hover: hover) and (pointer: fine) {
  .footer-social a:hover { color: var(--color-on-accent-muted); }
}

/* ─── Shared page styles (used across About, Play, etc.) ─── */

/* Intro paragraph — Inter 300, 24px muted */
.page-intro {
  font-family: var(--font-body);
  font-size: var(--text-body);
  font-weight: 300;
  line-height: 1.5em;
  color: var(--color-muted);
  margin-bottom: 80px;
}

/* Section block */
.page-section {
  position: relative;
  width: 100%;
  margin-bottom: 80px;
}

/* Section header row (heading + optional action). Wraps rather than
   forcing a nowrap action (e.g. "Download resume") to overflow the
   column on narrow screens. */
.page-section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  row-gap: 12px;
  margin-bottom: 40px;
}

/* Section heading — Heading 4: Fraunces regular 60px */
.section-heading {
  font-family: var(--font-display);
  font-size: var(--text-h4);
  font-weight: 400;
  line-height: 1.3em;
  letter-spacing: 0px;
  color: var(--color-text);
}

/* Section separator bar */
.section-sep-wrap {
  position: relative;
  height: 8px;
  margin-bottom: 40px;
}
.section-sep {
  position: absolute;
  inset: 0 0 4px;
  border-radius: 10px;
  background-color: var(--color-separator);
  overflow: hidden;
  box-shadow: 0 1px var(--color-separator-shadow);
}

/* ─── Artwork grid (Play page) ─── */
.artwork-grid {
  display: grid;
  /* minmax(0, 1fr): every cell is an <img> with a fixed height, so a bare
     1fr floors at the transferred size suggestion (height x aspect ratio),
     which for these 2892x2538 and 3072x3072 originals is wider than the
     track the layout means to give them. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px;
}
.artwork-grid img {
  width: 100%;
  height: 300px;
  object-fit: cover;
  border-radius: 24px;
  border: 1px solid var(--color-border);
  display: block;
}
.artwork-col-2 { grid-column: span 2; }

/* ─── Music grid (Play page) ─── */
.music-grid {
  display: grid;
  /* minmax(0, 1fr): the right column holds an iframe / a text button, both
     of which floor a bare 1fr at their own min-content beside the fixed
     340px track. */
  grid-template-columns: 340px minmax(0, 1fr);
  gap: 16px;
  height: 360px;
}
.music-right {
  display: flex;
  flex-direction: column;
  gap: 16px;
  height: 100%;
}
.music-player {
  border-radius: 16px;
  overflow: hidden;
  flex: 1;
}
.music-player--tall {
  border-radius: 16px;
  overflow: hidden;
  height: 100%;
}
.music-player iframe {
  width: 100%;
  height: 100%;
  border: none;
  display: block;
}
.music-player--tall iframe {
  width: 100%;
  height: 100%;
  border: none;
  display: block;
}
.soundcloud-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  background: var(--color-accent);
  color: var(--color-on-accent);
  cursor: pointer;
  font: inherit;
  text-align: center;
  padding: 16px;
}
.soundcloud-placeholder-icon {
  font-size: 20px;
}
.soundcloud-placeholder-label {
  font-size: 13px;
  opacity: 0.8;
}

/* ─── Artwork detail view ─── */
/* A <dialog>, so the focus trap, the inert page, ::backdrop and Esc are all
   the browser's job. The dialog itself is the full viewport and paints
   nothing: the picture and the controls are what is visible, and a click
   that lands on the dialog rather than on either of those is a click on the
   empty space, which closes it. */
.lightbox {
  width: 100vw;
  max-width: 100vw;
  height: 100dvh;
  max-height: 100dvh;
  margin: 0;
  padding: max(48px, env(safe-area-inset-top)) 24px max(40px, env(safe-area-inset-bottom));
  border: none;
  background: transparent;
  color: var(--color-text);
  overflow: hidden;
}
.lightbox::backdrop {
  background: rgb(10 11 11 / 0.88);
  backdrop-filter: blur(6px);
}

.lightbox-figure {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* safe centre: centred while the picture fits, top-aligned the moment it
     does not. Plain centring in a scroll container puts the overflow above
     the scrollable area, where it can never be reached. */
  justify-content: safe center;
  gap: 18px;
  height: 100%;
  margin: 0;
  /* The figure is the only thing a click must not close on, so it is sized
     to the picture rather than stretched across the dialog. */
  width: fit-content;
  margin-inline: auto;
  min-height: 0;   /* so the scroller inside can actually shrink */
}

/* The rounded window the picture moves inside. The radius and the clipping
   live here rather than on the image: on the image, scrolling a tall page
   put square cut edges at the top and bottom of the visible area, because
   what was being clipped was the picture's own corners rather than the
   window's. */
.lightbox-media {
  /* Sized to the picture, not to the space available. Left to fill the
     figure, the window sat wider than the image inside it, so the rounded
     corners belonged to the box while the picture's own straight edge showed
     against them, which is the opposite of the point. */
  width: fit-content;
  max-width: 100%;
  min-height: 0;
  overflow: hidden;
  /* Larger than the site's usual tile radius on purpose. At 16px against a
     1200px picture the cut was so slight that the image's own square corners
     still read as square, which is the one thing the window is there to
     hide. */
  border-radius: 28px;
  background: var(--color-card);
  box-shadow: 0 24px 60px rgb(0 0 0 / 0.5);
}
.lightbox-img {
  display: block;
  max-width: min(1200px, 100%);
  /* Everything fits, whatever its shape. A tall page therefore comes out
     small, and that is the trade being made deliberately: one behaviour for
     every picture beats a rule that reads a page one way and a poster
     another. Someone who wants the page at full size opens the file. */
  max-height: 100%;
  width: auto;
  height: auto;
  flex-shrink: 0;
  object-fit: contain;
}
.lightbox-caption {
  max-width: 62ch;
  margin: 0;
  color: var(--color-muted);
  font-size: 13px;
  line-height: 1.5;
  text-align: center;
}
.lightbox-count {
  position: absolute;
  left: 0;
  right: 0;
  bottom: max(16px, env(safe-area-inset-bottom));
  margin: 0;
  color: var(--color-muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 11px;
  letter-spacing: 0.14em;
  text-align: center;
  pointer-events: none;
}

.lightbox-btn {
  position: absolute;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid rgb(255 255 255 / 0.14);
  border-radius: 50%;
  background: rgb(255 255 255 / 0.06);
  color: var(--color-text);
  cursor: pointer;
  transition: background 0.2s ease, border-color 0.2s ease;
}
.lightbox-btn svg { width: 20px; height: 20px; }
.lightbox-btn--close { top: max(16px, env(safe-area-inset-top)); right: 20px; }
.lightbox-btn--prev  { top: 50%; left: 20px; transform: translateY(-50%); }
.lightbox-btn--next  { top: 50%; right: 20px; transform: translateY(-50%); }
@media (hover: hover) and (pointer: fine) {
  .lightbox-btn:hover {
    background: rgb(255 255 255 / 0.14);
    border-color: rgb(255 255 255 / 0.28);
  }
}
.lightbox-btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
}

/* Below the tablet breakpoint the side buttons would sit on top of the
   picture, so they drop to the foot of the screen either side of the count. */
@media (max-width: 700px) {
  .lightbox { padding-inline: 12px; }
  .lightbox-img { max-height: calc(100dvh - 240px); }
  .lightbox-btn--prev { top: auto; bottom: max(48px, calc(env(safe-area-inset-bottom) + 32px)); left: 24px; transform: none; }
  .lightbox-btn--next { top: auto; bottom: max(48px, calc(env(safe-area-inset-bottom) + 32px)); right: 24px; transform: none; }
}

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

/* ─── Responsive ─── */
@media (max-width: 1080px) {
  :root { --pad-page: 20px; }
}

/* Below this the fixed artwork column leaves the heading less room than
   "Stories/" needs, and the grid overflows the page. Stack instead. */
@media (max-width: 1000px) {
  .stories-section { grid-template-columns: 1fr; gap: 16px; }
  /* Column is no longer fixed once stacked, so let the size go fluid. */
  .stories-desc { font-size: var(--text-body); }

  /* The artwork held its full 488px from here down to 768, where it
     vanished — so on an iPad Air it sat straight on top of the name.
     Redefining --art-width rather than sizing the canvas directly means
     the separator under the artwork tracks it too; the right rule is
     declared as width: var(--art-width) and had been pointing at a 488px
     column the artwork no longer occupied.
     At 1000 this still resolves to 488, so the desktop hero is unchanged;
     it only shrinks once there is a reason to. */
  :root { --art-width: min(488px, calc(100vw - 440px)); }

  .hero { height: calc(var(--art-width) + 20px); }
  #sketch-container {
    width: var(--art-width);
    height: auto;
    aspect-ratio: 488 / 490;
  }
}

@media (max-width: 768px) {
  .nav-pill { width: auto; gap: 8px; }
  .nav-item { width: auto; padding: 0 14px; font-size: 13px; }
  .nav-social { width: auto; padding-right: 8px; gap: 16px; }


  .recent-card { padding: 24px 20px; gap: 32px; }
  .footer-card { --footer-pad-x: 20px; }

  .page-intro { margin-bottom: 48px; }

  .artwork-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .artwork-grid img { height: 180px; }
  .artwork-col-2 { grid-column: span 1; }

  .music-grid { grid-template-columns: 1fr; height: auto; }
  .music-player--tall { height: 240px; }
  .music-right { height: auto; }
  .music-player { height: 160px; flex: none; }
}

/* Tightest breakpoint: fit the four nav pills without wrapping down to
   360px. Social icons drop from the nav (they remain in the footer). */
@media (max-width: 640px) {
  :root { --pad-page: 16px; }
  /* The social links go, but the theme control stays. Hiding the whole
     .nav-social took the theme switcher with it — it is a child of that
     block — so below 640 a visitor could not change theme at all. The
     links are reachable from the footer; the theme control is not
     anywhere else. */
  .site-nav-wrapper { display: flex; align-items: center; }
  .nav-social {
    position: static;
    width: auto;
    padding: 0 12px 0 4px;
    gap: 0;
  }
  .nav-social > a { display: none; }
  /* 44px to match the pills' tap target, even though the glyph is 32. */
  .theme-toggle { width: 44px; height: 44px; }

  /* 16px, matching --pad-page here: the copyright then sits 32px from the
     edge of the screen, which is where the live site puts it. */
  .footer-card { --footer-pad-x: 16px; }

  /* The bar had been the full width of the page with four icons sitting in
     the left corner of it, because .nav-social fills the right-hand end and
     is hidden here. Shrink it to its contents so the padding matches on
     both sides and it reads as a control rather than a banner.

     And it moves to the bottom, where a thumb is. 12px clear of the safe
     area, not of the viewport — on an iPhone that keeps it above the home
     indicator instead of underneath it. The fallback 0px matters: env()
     is unknown to older browsers and an unparseable calc() would drop the
     whole declaration, leaving the bar pinned to the very bottom edge. */
  .site-nav-wrapper {
    width: auto;
    min-height: 0;
    top: auto;
    bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  }

  /* The page no longer has to clear a bar at the top. */
  .main { padding-top: 40px; }

  /* The footer is the last thing on the page, not .main, so it is what the
     bar would otherwise cover — measured 107px of overlap before this.
     60px of bar (44px pill + 8px padding each side) plus the 12px gap plus
     the inset, on top of its own 24px. */
  .footer-card {
    padding-bottom: calc(24px + 72px + env(safe-area-inset-bottom, 0px));
  }
  .nav-pill {
    position: static;
    width: auto;
    max-width: none;
    gap: 12px;
    padding: 8px;
  }

  /* One line, not two. The two spans are separate elements so the
     mask-reveal can stagger them, and being blocks they always broke.
     Flex puts them on one line AND drops the whitespace between them, so
     it reads "Stories/Work" rather than "Stories/ Work" — and flex items
     are blockified, so the reveal's transform still applies. */
  .stories-heading {
    display: flex;
    flex-wrap: nowrap;
  }

  /* Icons instead of words. Four labels plus the theme control do not fit
     here, and shrinking the type further was making it unreadable rather
     than smaller. The pills stay — same size, same active state, same
     tap target — they just carry a glyph now. */
  .nav-item { width: 44px; height: 44px; padding: 0; }
  .nav-icon { display: block; }
  /* Visually hidden, not removed: this is the link's accessible name, and
     a screen reader should still hear "Stories", not "link". Same
     technique as .sr-only. */
  .nav-item > span {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  /* The two hero columns stop being worth having here, so the hero stacks:
     identity, a rule, then the artwork at the full width of the column.
     Side by side the artwork would be 160px at a 600 viewport and 60px at
     500, which is not a picture. Stacked it is the width of the page, which
     is a better one than the desktop ever gets. */
  .hero {
    display: flex;
    flex-direction: column;
    height: auto;
    min-height: 0;
    margin-bottom: 48px;
  }
  /* Out of the corner it was pinned to, and carrying no padding of its
     own, which is what removes the empty band above and below the logo. */
  .hero-identity {
    position: static;
    padding-top: 0;
    width: auto;
    height: auto;
    gap: 14px;
  }
  .site-logo { width: 56px; height: 56px; }
  .hero-name { font-size: 20px; }

  /* The artwork returns, square and full width, with the divider above it.
     The artwork cannot be placed after .separators without moving markup,
     so the bar is drawn here instead — and drawn as the same object, not as
     a border: 4px tall, 10px radius, the separator's own colour and its 1px
     shadow. A hairline in its place was a different shape wearing the same
     job. */
  .hero-sketch {
    display: block;
    position: relative;
    margin-top: 28px;
  }
  .hero-sketch::before {
    content: '';
    display: block;
    height: 4px;
    margin-bottom: 28px;
    border-radius: 10px;
    background-color: var(--color-separator);
    box-shadow: 0 1px var(--color-separator-shadow);
    transition: background-color 0.4s ease, box-shadow 0.4s ease;
  }
  #sketch-container {
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
  }
  /* Pinned to the hero's own corner rather than the page's, now that the
     hero is no longer a fixed 510px frame. */
  .palette-bar { bottom: 12px; right: 12px; }

  /* The rule below the hero has moved up to the artwork's top edge, so the
     pair that used to sit here would be a second line saying the same
     thing. */
  .separators { display: none; }

  /* 32px of side padding is a desktop figure. At a 500px viewport the
     article column is 453px, so a callout was setting 18px text in 389px
     and the stats box the same. 20px hands back 24px of measure without
     letting the fill touch its own text. */
  .story-body .callout { padding: 24px 20px; }
  .story-body .stats { padding: 4px 20px; }
}

@media (max-width: 480px) {
  /* Square corners AND a 16px gutter left the footer as a rectangle
     floating with dark strips down either side. The radius went to 0 here
     because the card was meant to run edge to edge at this width — the
     half of that which actually removes the gutter was missing. The live
     Framer site does the same thing: its mobile footer starts at x=0.
     --footer-pad-x stays 32 so the copyright does not jump 16px sideways
     as it crosses this breakpoint. */
  .site-footer { max-width: none; padding: 0; }
  .footer-card {
    flex-direction: column;
    gap: 16px;
    align-items: flex-start;
    justify-content: center;
    border-radius: 0;
    --footer-pad-x: 32px;
  }
  .artwork-grid { grid-template-columns: minmax(0, 1fr); }
  .artwork-col-2 { grid-column: span 1; }
}

/* ─── Article page (/stories/<slug>/) ─── */
/* The title leads now, so the page has to clear the fixed nav again. The
   page is also narrowed to the text's own measure rather than the site's
   1280px frame — title, artwork and prose on one column. */
.story-main {
  max-width: calc(var(--measure) + var(--pad-page) * 2);
  padding-top: calc(var(--nav-top) + var(--nav-height) + 48px);
}

/* Full-bleed slot at the top of an article: a still, a video, or a live
   sketch. It clears the fixed nav, which previously sat over it. */
/* Back inside the content column and rounded, rather than full bleed: the
   title now sits above it, so a band running to the screen edges would cut
   the page in half between the title and the piece. */
.story-cover {
  position: relative;
  display: block;
  width: 100%;
  /* --radius-tile, not --radius-card: the same corner the Stories tiles
     use, so a tile and the page it opens read as the same object. 40px
     belongs to the page-level cards (Recent, the footer), not to content. */
  border-radius: var(--radius-tile);
  overflow: hidden;
  margin-bottom: 32px;
}
.story-cover--media { max-height: var(--cover-max-h, 60vh); }
.story-cover--media img,
.story-cover--media video,
.story-cover picture {
  display: block;
  width: 100%;
  max-height: var(--cover-max-h, 60vh);
  object-fit: cover;
}
/* No still to size it, so the slot carries its own shape — and a height
   cap, because once this runs edge to edge a 5:2 band would be 1,000px
   tall on a wide screen. The cap wins; the ratio only governs narrow
   viewports. */
.story-cover--sketch {
  aspect-ratio: 5 / 2;
  max-height: 340px;
  background: #0C0E0E;
}

/* No ground of its own — it sits on the page, so its text takes the page's
   own colours rather than the on-accent set it used inside the navy card. */
.story-meta-card {
  padding: 0;
  margin-bottom: 40px;
}

.story-meta-header { margin-bottom: 40px; }

.story-meta-title {
  font-family: var(--font-display);
  font-size: var(--text-h2);
  font-weight: 400;
  /* 1.05, not 1.15: at 88px the extra leading opens gaps between the lines
     of a single title, so it stops reading as one object. */
  line-height: 1.05em;
  color: var(--color-text);
  margin: 0;
}

/* Not four equal columns: that put each label at the left of a wide column
   with its value stranded at the left of the next one. The label columns
   are now only as wide as their text, so a value sits beside its own label;
   the pairs are held apart by the padding rule below, not by the gap. */
.story-meta-table {
  display: grid;
  /* Every column content-sized, not 1fr: a stretched value column keeps the
     cell boxes adjacent while leaving the text itself stranded, which is
     what this looked like before. */
  grid-template-columns: repeat(4, auto);
  justify-content: start;
  column-gap: 16px;
  /* Two rows of one-line text do not need a 24px trench between them. With
     the label's stale margin gone as well, this takes the row pitch from
     56px to 38px. */
  row-gap: 12px;
  align-items: baseline;
}
/* Third cell in each row — the second pair's label — carries the gutter
   between the two pairs. */
.story-meta-table > *:nth-child(4n + 3) { padding-left: 56px; }

.story-meta-label {
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-muted);
  /* No margin-bottom: it is left over from a layout where the label sat
     above its value. They are side by side on a shared baseline now, so it
     only ever padded the row out. */
}

.story-meta-value {
  font-family: var(--font-body);
  font-size: var(--text-body4);
  color: var(--color-body);
}

/* Full page measure, matching the rest of the site, rather than a 720px
   reading column. Text is capped separately below — a paragraph at 1200px
   runs past 110 characters a line — while figures, tables and code use the
   whole width. */
.story-body {
  max-width: 100%;
  margin: 0 auto;
  padding: 8px 0 0;
  position: relative;
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1.75em; /* 31.5px */
  color: var(--color-body);
}

.story-body h2,
.story-body h3 {
  font-family: var(--font-display);
  font-weight: 400;
  color: var(--color-text);
  /* 56px above against 16px below: a heading binds to the text it
     introduces, and now that paragraphs are 40px apart a 40px gap above a
     heading no longer read as a section break at all. */
  margin: 56px 0 16px;
}

.story-body h3 { font-size: 28px; line-height: 1.3em; }
.story-body h2 { font-size: 36px; line-height: 1.25em; }

/* The column is already the measure, so this only matters if something
   inside it is ever allowed to run wider. */
.story-body p,
.story-body ul,
.story-body ol,
.story-body h2,
.story-body h3,
.story-body blockquote { max-width: var(--measure); }

/* 40px, not 24px. The body line-height is 31.5px, so a 24px paragraph gap
   was SMALLER than the gap between lines inside a paragraph — the eye got
   no stronger signal at a paragraph break than at a line break. This is
   1.27x the line height, inside the usual 1.2-1.5 band. */
.story-body p { margin: 0 0 40px; }

/* Never upscale. width: 100% was stretching every image to the 828px
   column, including the ones Framer only ever held at 400-750px — the
   phone screenshots went to 828x1793, blown up 1.10x and blurred for it.
   These are the original files; Framer has nothing sharper (checked: all
   58 rasters are byte-identical to what it serves, and scale-down-to=4096
   returns the same file). So the fix is to stop asking them to be bigger
   than they are. Anything at or above 828px still fills the column. */
.story-body img,
.story-body video {
  display: block;
  width: 100%;
  /* The per-image cap is an inline max-width set from the manifest — CSS
     cannot read an element's intrinsic size, and width: auto would let a
     lazy image collapse to nothing before it loads, undoing the reserved
     box that the width/height attributes exist to provide. */
  max-width: 100%;
  height: auto;
  margin-inline: auto;
  border-radius: var(--radius-tile);
}

/* The <picture> around a WebP-backed image is inline by default, which
   would leave margin-inline: auto on the img with nothing to centre in. */
.story-body picture {
  display: block;
}

/* A vector has no resolution to lose, so it is not a photograph that has
   been shrunk to fit — it is a mark, and it belongs at its own size on the
   text's own left edge rather than centred like a figure. The manifest
   gives it a max-width, so it lands at 190px here. */
.story-body img[src$=".svg"] {
  margin-inline: 0;
}

/* A mark introduces the paragraph under it, so it sits close to it rather
   than a full paragraph-break away. Browsers without :has() keep the 40px,
   which is what this looked like before — no worse, just less tight. */
.story-body p:has(> img[src$=".svg"]) {
  margin-bottom: 20px;
}

/* Tables. notes-that-do-something is the first article to use one, so the
   stylesheet had nothing for them and markdown-it's bare <table> rendered
   with no padding, no rules and centred headers. */
.story-body table {
  width: 100%;
  border-collapse: collapse;
  margin: 48px 0;
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.5em;
}

.story-body th,
.story-body td {
  text-align: left;
  vertical-align: top;
  padding: 12px 20px 12px 0;
  border-bottom: 1px solid var(--color-border);
}
.story-body td:last-child,
.story-body th:last-child { padding-right: 0; }

.story-body th {
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-muted);
  padding-bottom: 10px;
}

.story-body tbody tr:last-child td { border-bottom: none; }

.story-body figure {
  margin: 48px 0;
}

.story-body figcaption {
  font-family: var(--font-body);
  font-size: 12px;
  line-height: 1.2em; /* 14.4px */
  color: var(--color-muted);
  margin-top: 12px;
}

/* Self-scrolling image row, as on the Framer original: six phone
   screenshots at 375px with 24px between them, drifting left forever
   behind a mask that fades both ends.
   Full-bleed out of the measure — the point is that it runs past the
   edges of the text, so the row reads as continuing rather than ending. */
.story-body .carousel {
  position: relative;
  /* Column width, not full-bleed. Running it to the viewport edges left
     the row floating free of the heading above it, which is what made the
     placement read as wrong. The Framer original was a 1000px section in
     a 1000px page — the same width as the text, not wider than it. */
  width: 100%;
  overflow: hidden;
  margin-block: 48px;
  /* No edge fade. It existed to say "this row continues", but a row that is
     visibly moving says that by itself, and a gradient mask over a white
     screenshot reads as a printing fault rather than as a window. The row is
     cut cleanly instead, on the same corner radius every other surface on
     the site uses, so it looks like a thing rather than a thing that has
     been rubbed out at both ends. */
  border-radius: var(--radius-tile);
}

.story-body .carousel-track {
  display: flex;
  width: max-content;
  animation: carousel-drift var(--carousel-duration, 60s) linear infinite;
}

/* Pause on hover so a reader can actually look at one. */
@media (hover: hover) and (pointer: fine) {
  .story-body .carousel:hover .carousel-track { animation-play-state: paused; }
}

.story-body .carousel-set {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
  max-width: none;   /* the .story-body ul measure cap does not apply here */
}

/* Spacing lives on the item, not as a track gap — see the note in
   markdown.js on why -50% would otherwise miss by half a gap. */
.story-body .carousel-set > li { margin-right: 24px; }

.story-body .carousel img,
.story-body .carousel picture {
  display: block;
  width: 375px;      /* 750px screenshots at 2x, as the original showed them */
  max-width: none;
  margin-inline: 0;
  border-radius: var(--radius-tile);
}
/* One height for the row, so a set of screens of differing length does not
   leave a ragged bottom edge. The value comes from the block itself, which
   measures its own images and takes the shortest, so a set that is already
   uniform is not cropped at all. Cropped from the top, because the top of a
   screen is where its heading is and the foot is usually padding. */
.story-body .carousel img {
  height: var(--carousel-h, auto);
  object-fit: cover;
  object-position: top center;
}
.story-body .carousel picture { height: var(--carousel-h, auto); }

/* Cards arrive. Both classes are put on by carousel.js and neither exists in
   the markup, so with the script gone every card is simply visible, which is
   the only acceptable default.
   The transition is on .is-in rather than on the card, so the reset back to
   waiting happens instantly and off-screen: transitioned, a card would be
   seen fading back out as it left. */
.story-body .carousel-set > li.is-waiting {
  opacity: 0;
  filter: blur(8px);
  transform: scale(0.98);
}
.story-body .carousel-set > li.is-in {
  opacity: 1;
  filter: blur(0);
  transform: none;
  transition:
    opacity 520ms ease-out,
    filter 520ms ease-out,
    transform 520ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
  .story-body .carousel-set > li.is-waiting { opacity: 1; filter: none; transform: none; }
  .story-body .carousel-set > li.is-in { transition: none; }
}

@keyframes carousel-drift {
  to { transform: translateX(-50%); }
}

/* Reduced motion: stop the drift and let the row be scrolled by hand
   instead, so every image is still reachable. The duplicate set would
   only be the same six images twice, so it goes. */
@media (prefers-reduced-motion: reduce) {
  .story-body .carousel { overflow-x: auto; }
  .story-body .carousel-track { animation: none; }
  .story-body .carousel-set[aria-hidden="true"] { display: none; }
}

/* ─── The consistency diagram ─── */
/* Three ways of running the same brief, eight runs each. The first row
   scatters, the second settles, the third is flat and lit: the argument is
   made by the shape before the labels are read.
   Drawn here rather than pasted in from the deck it came from, which is
   white with a red accent and would have been right in one theme and wrong
   in the other. */
.story-body .runs {
  margin: 40px 0;
  padding: 28px 32px;
  list-style: none;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-tile);
  background: var(--color-card);
  max-width: none;
}
.story-body .runs-row { margin: 0; padding: 22px 0; }
.story-body .runs-row::before { content: none; }
.story-body .runs-row + .runs-row { border-top: 1px solid var(--color-border); }

.story-body .runs-label {
  margin: 0 0 14px;
  font-size: 16px;
  line-height: 1.4;
  color: var(--color-muted);
}
.story-body .runs-label strong {
  font-weight: 500;
  color: var(--color-text);
}
.story-body .runs-label span { margin-left: 8px; }

/* Eight runs to a row. Each bar's width and height come from the rule that
   built it, as percentages of a base, so the row is one shape rather than
   eight decisions. */
.story-body .runs-bars {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  height: 44px;
}
.story-body .runs-bars span {
  flex: 1 1 0;
  max-width: calc(var(--w) * 0.14%);
  height: calc(var(--h) * 0.36px);
  border-radius: 5px;
  background: var(--color-border);
}
/* The last row is the one the section is arguing for, so it is the one that
   is lit. --color-story-hover, not --color-accent: the accent is the dark
   navy of the metadata card, which against a dark card is barely a colour
   at all. */
.story-body .runs-row:last-child .runs-bars span {
  background: var(--color-story-hover);
  opacity: 0.9;
}
.story-body .runs-row:nth-child(2) .runs-bars span { background: var(--color-muted); opacity: 0.45; }

.story-body .runs-caption {
  margin: 6px 0 0;
  font-size: 13px;
  color: var(--color-muted);
  opacity: 0.75;
}

@media (max-width: 640px) {
  .story-body .runs { padding: 20px 20px; }
  .story-body .runs-bars { gap: 5px; height: 36px; }
}

/* ─── Gallery ─── */
/* Thumbnails that open full size. These posters print their own legend, so
   at thumbnail size they are an index rather than something to read: small
   enough to compare three at a glance, and a click away from being legible. */
.story-body .gallery {
  display: grid;
  /* auto-fit, not a fixed three: a pair of images should split the row
     evenly rather than sit in the first two of three columns with a gap
     where the third would be. */
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin: 40px 0;
  padding: 0;
  list-style: none;
  max-width: none;
}
.story-body .gallery-item { margin: 0; padding: 0; }
.story-body .gallery-item::before { content: none; }
.story-body .gallery-item a {
  display: block;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-tile);
  overflow: hidden;
  /* The posters are drawn on their own off-white; a dark card behind them
     would show as a rim. */
  background: #f4f3ee;
  transition: transform 0.25s cubic-bezier(0.25, 0, 0, 1), border-color 0.25s ease;
}
.story-body .gallery-item img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: center 22%;
}
@media (hover: hover) and (pointer: fine) {
  .story-body .gallery-item a:hover {
    transform: translateY(-3px);
    border-color: var(--color-muted);
  }
}
.story-body .gallery-item a:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
}
@media (max-width: 700px) {
  .story-body .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
}
@media (prefers-reduced-motion: reduce) {
  .story-body .gallery-item a { transition: none; }
}

/* ─── Diagram blocks ─── */
/* Both are an ordinary markdown list with CSS making the picture. Drawn in
   the page rather than pasted in as a screenshot, so they take the site's
   colours and switch with the theme: an image of a diagram is right in one
   theme and wrong in the other. */

/* layers: a bullet list becomes a row of cards. */
.story-body .layer-row { margin: 40px 0; }
.story-body .layer-row ul {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
  max-width: none;
}
.story-body .layer-row li {
  margin: 0;
  padding: 22px 24px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-tile);
  background: var(--color-card);
  font-size: 15px;
  line-height: 1.6;
  color: var(--color-muted);
}
.story-body .layer-row li::before { content: none; }
.story-body .layer-row li strong {
  display: block;
  margin-bottom: 8px;
  font-family: var(--font-display);
  font-size: 20px;
  font-weight: 400;
  color: var(--color-text);
}
/* The last card is the output, which is the point of the other two. */
.story-body .layer-row li:last-child { background: var(--color-card-lit); }

/* flow: a numbered list becomes a pipeline, with an arrow between steps.
   The counter is drawn rather than using the list's own marker, which
   cannot be positioned inside the card. */
.story-body .flow-row { margin: 40px 0; }
.story-body .flow-row ol {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: flow;
  max-width: none;
}
.story-body .flow-row li {
  counter-increment: flow;
  position: relative;
  margin: 0;
  padding: 18px 20px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-tile);
  background: var(--color-card);
  font-size: 14px;
  line-height: 1.55;
  color: var(--color-muted);
}
.story-body .flow-row li::before {
  content: "0" counter(flow);
  display: block;
  margin-bottom: 10px;
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 11px;
  letter-spacing: 0.14em;
  color: var(--color-muted);
  opacity: 0.6;
  position: static;
}
.story-body .flow-row li strong {
  display: block;
  margin-bottom: 6px;
  font-size: 15px;
  font-weight: 500;
  color: var(--color-text);
}

/* Chips, written as a nested list so they form their own block. Inline
   code alone left the first chip hanging on the end of the description's
   last line, which read as a typo rather than as a label. */
.story-body .flow-row li ul {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
.story-body .flow-row li ul li {
  margin: 0;
  padding: 0;
  border: none;
  background: none;
  counter-increment: none;
}
.story-body .flow-row li ul li::before,
.story-body .flow-row li ul li::after { content: none; }

.story-body .flow-row li code {
  display: inline-block;
  padding: 3px 8px;
  border: 1px solid var(--color-border);
  border-radius: 100px;
  background: none;
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 11px;
  line-height: 1.5;
  color: var(--color-muted);
}

/* The knowledge base is the middle of the loop, so it is the lit card. */
.story-body .flow-row li:nth-child(3) { background: var(--color-card-lit); }

/* The return loop, drawn rather than described. Without it the row is a
   one-way pipeline, which is the opposite of the point: what comes out of
   the far end goes back into the middle, and that is the whole reason the
   thing compounds.
   The path is one box with three of its four borders showing, which gives
   the down-across-up shape in a single element: down from under Outputs,
   left along the bottom, up into the knowledge base. Five equal columns
   put their centres at 10/30/50/70/90 per cent, so the two ends are at
   50% and 90%. */
.story-body .flow-row { position: relative; }
/* Only a flow that carries a trailing label gets the return path. A flow
   that is a straight sequence has nothing to loop back to, and an arrow
   drawn anyway would point at the wrong card. */
.story-body .flow-row:has(> p) { padding-bottom: 92px; }

.story-body .flow-row:has(> p)::before {
  content: "";
  position: absolute;
  left: 50%;
  right: 10%;
  bottom: 44px;
  height: 32px;
  border: 1px dashed var(--color-border);
  border-top: none;
  pointer-events: none;
}

/* The arrowhead, pointing up into the card the work returns to. */
.story-body .flow-row:has(> p)::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 76px;
  margin-left: -4px;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-bottom: 6px solid var(--color-muted);
  pointer-events: none;
}

/* The label sits below the path, not on it. On it, the text's background
   had to break the dashed line to stay readable, which left two orphaned
   vertical stubs and an arrowhead floating with nothing joining them. */
.story-body .flow-row > p {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 6px;
  margin: 0;
  padding: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 11px;
  letter-spacing: 0.06em;
  line-height: 1.5;
  color: var(--color-muted);
  text-align: center;
  max-width: none;
}
/* The arrow sits in the gap, so it is on the item rather than between two
   of them, and the last one has nothing to point at. */
.story-body .flow-row li::after {
  content: "";
  position: absolute;
  top: 50%;
  right: -12px;
  width: 12px;
  height: 1px;
  background: var(--color-border);
}
.story-body .flow-row li:last-child::after { content: none; }
/* Once the grid wraps, the arrows point at nothing, and the return path's
   50%/90% no longer land on the cards it is meant to join. The label goes
   back to being a line of text under the row. */
@media (max-width: 700px) {
  .story-body .flow-row li::after { content: none; }
  .story-body .flow-row:has(> p) { padding-bottom: 0; }
  .story-body .flow-row:has(> p)::before,
  .story-body .flow-row:has(> p)::after { content: none; }
  .story-body .flow-row > p {
    position: static;
    width: auto;
    margin: 20px 0 0;
    transform: none;
    padding: 12px 18px;
    border: 1px dashed var(--color-border);
    border-radius: 100px;
    background: none;
  }
}

/* ─── Reading bar (phones only) ─── */
/* Hidden by default and shown at the phone breakpoint: on a desktop the nav
   is already at the top and the way back is a click away in it. */
.story-bar { display: none; }

@media (max-width: 640px) {
  .story-bar {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 40;
    display: flex;
    align-items: center;
    height: 44px;
    padding: 0 var(--pad-page);
    background: var(--color-nav-blur);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
  }
  .story-bar-back {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;      /* or the title cannot be truncated */
    color: var(--color-nav-text);
  }
  .story-bar-back svg { width: 18px; height: 18px; flex-shrink: 0; }
  .story-bar-title {
    font-size: 13px;
    line-height: 1.2;
    /* One line, cut where it runs out. A title that wraps would double the
       bar's height, and the bar exists to cost as little as possible. */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* The progress line sits on the bar's bottom edge and is scaled rather
     than resized, so it animates on the compositor and the scroll handler
     never touches layout. */
  .story-bar-progress {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 2px;
    width: 100%;
    transform: scaleX(var(--read, 0));
    transform-origin: left center;
    background: var(--color-story-hover);
  }

  /* Room for the bar, and only on the pages that have one. :has rather than
     a class on <body>, because the bar is printed by the story template and
     nothing else needs to know it exists. */
  body:has(.story-bar) .main { padding-top: calc(44px + 24px); }
}

@media (prefers-reduced-motion: reduce) {
  .story-bar-progress { transition: none; }
}

/* ─── Image placeholder ─── */
/* A space held open for artwork that has not been made yet. Loud on
   purpose: this is scaffolding, and scaffolding that blends in gets left
   up. The text inside is the brief for the picture that goes here. */
.story-body .img-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
  margin: 40px 0;
  padding: 28px 32px;
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-tile);
  background: var(--color-card);
  color: var(--color-muted);
  text-align: center;
}
.story-body .img-slot > * { margin: 0; max-width: 52ch; }
.story-body .img-slot p {
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 13px;
  line-height: 1.6;
}
/* The label, so the box reads as a slot rather than as a caption someone
   forgot to attach a picture to. */
.story-body .img-slot p::before {
  content: "IMAGE ";
  letter-spacing: 0.16em;
  opacity: 0.55;
}

/* ─── Curiosities: catalogue cards ─── */
/* A card catalogue is a keeping object — someone read a thing and wrote
   down what it was so it could be found again — which is what this section
   is for. A receipt, which this replaced, records a transaction and is
   designed to be thrown away.
   One card per entry, on the same grid rhythm as the Stories tiles, so
   twenty entries stay a page rather than becoming an absurd roll. */
/* ─── The drawer ─── */
/* Cards are filed by month behind a guide tab, the way a card drawer is
   divided. The tabs sit on one horizontal axis whichever is open, and the
   cards drop out underneath the whole rail rather than shoving the other
   folders down a line.
   The tabs and the cards are therefore siblings, not nested: a rail that
   scrolls sideways cannot also be the thing that gives the cards the full
   width of the drawer. aria-controls is what joins a tab to its stack. */
.curio-drawer { max-width: none; }

/* The rail. A row of folders that runs off the edge rather than wrapping,
   because a wrapped second line of folders reads as a second drawer. */
.curio-rail {
  display: flex;
  align-items: flex-start;
  gap: 32px;
  list-style: none;
  /* The open flap leans out on a perspective, so its top corners sit wider
     than the folder box, and the whole face lifts 8px. overflow-x: auto
     makes the cross axis clip too, so the rail is padded on all four sides
     and the padding paid back as negative margin: without it the first
     folder's flap was sliced off flat against the left edge. */
  margin: -14px -16px 22px;
  padding: 14px 16px 4px;
  max-width: none;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  /* The folders are the furniture, not the content; a scrollbar under them
     competes with the cards below for the reader's attention. */
  scrollbar-width: none;
}
.curio-rail::-webkit-scrollbar { display: none; }

.curio-folder {
  flex: 0 0 300px;
  scroll-snap-align: start;

  /* Manila by default; the cycle below gives each month a different board
     so a rail of folders is not five copies of one object. All five are
     desaturated to roughly the same value, so no single month shouts and
     the open one still stands out by being lit rather than by being
     louder. Colour is the whole of the difference: a hatch texture over
     the top was tried and taken off again. */
  --folder-back:    #c3a468;
  --folder-front-a: #dcbd7f;
  --folder-front-b: #d2b072;
  --folder-open-a:  #e6c98f;
  --folder-ink:     #4a3b1c;
}

/* Sage */
.curio-rail > .curio-folder:nth-child(5n + 2) {
  --folder-back:    #97a888;
  --folder-front-a: #c6d1b6;
  --folder-front-b: #b8c5a7;
  --folder-open-a:  #d3ddc4;
  --folder-ink:     #363f29;
}
/* Dusty blue */
.curio-rail > .curio-folder:nth-child(5n + 3) {
  --folder-back:    #8b9fb1;
  --folder-front-a: #bed0dd;
  --folder-front-b: #b0c3d2;
  --folder-open-a:  #cdddea;
  --folder-ink:     #2b3a46;
}
/* Clay */
.curio-rail > .curio-folder:nth-child(5n + 4) {
  --folder-back:    #bc9184;
  --folder-front-a: #e0c0b4;
  --folder-front-b: #d5b2a5;
  --folder-open-a:  #eccec2;
  --folder-ink:     #4a3129;
}
/* Warm grey */
.curio-rail > .curio-folder:nth-child(5n + 5) {
  --folder-back:    #a7a199;
  --folder-front-a: #d9d3c9;
  --folder-front-b: #cdc6ba;
  --folder-open-a:  #e5e0d7;
  --folder-ink:     #3c3730;
}
.curio-folder-heading { margin: 0; }

/* ─── The folder ─── */
/* A manila folder: back panel with a tab, a few sheets showing above the
   front panel, month on the front. Built from four spans rather than an
   image so it takes the theme's page colour behind it and stays sharp at
   any size. */
.curio-folder-face {
  position: relative;
  display: block;
  width: 300px;
  max-width: 100%;
  height: 208px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  text-align: left;
  transition: transform 0.25s var(--anim-rise-ease, ease);
}
@media (hover: hover) and (pointer: fine) {
  .curio-folder-face:hover { transform: translateY(-4px); }
}

/* Back panel, with the tab cut into its top left — the silhouette that says
   folder before any colour does. */
.curio-folder-back {
  position: absolute;
  inset: 0;
  background: var(--folder-back);
  border-radius: 4px 10px 10px 10px;
  clip-path: polygon(0 14%, 0 0, 42% 0, 48% 14%, 100% 14%, 100% 100%, 0 100%);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.32);
}

/* Sheets, sitting between the two panels and standing a little proud of the
   front so the folder reads as full. */
.curio-folder-papers { position: absolute; inset: 0; }
.curio-leaf {
  position: absolute;
  left: 14px;
  right: 14px;
  height: 96px;
  bottom: 74px;
  background: #f2efe6;
  border-radius: 2px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.curio-leaf--2 { left: 20px; right: 8px; bottom: 80px; transform: rotate(0.8deg); background: #eae5d6; }
.curio-leaf--3 { left: 8px; right: 22px; bottom: 86px; transform: rotate(-1deg); background: #efece2; }

/* Front panel, a shade lighter than the back so the fold catches light. */
.curio-folder-front {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 74%;
  background: linear-gradient(180deg, var(--folder-front-a) 0%, var(--folder-front-b) 100%);
  border-radius: 8px 8px 10px 10px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
}

.curio-folder-label {
  position: absolute;
  left: 22px;
  bottom: 22px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  color: var(--folder-ink);
}
.curio-folder-month { font-size: 14px; font-weight: 700; letter-spacing: 0.18em; }
.curio-folder-count { font-size: 10px; letter-spacing: 0.14em; opacity: 0.6; }

/* Open: the folder stays where it is in the rail and is pulled forward
   instead. The front flap falls open on its bottom edge, which is the
   motion the object actually makes, and the sheets stand further out of it
   as a result. rotateX rather than a height change so the panel keeps its
   proportions and reads as a flap rather than as a shrinking rectangle. */
.curio-folder.is-open .curio-folder-face { transform: translateY(-8px); }
.curio-folder.is-open .curio-folder-front {
  background: linear-gradient(180deg, var(--folder-open-a) 0%, var(--folder-front-a) 100%);
  transform: perspective(680px) rotateX(-17deg);
  transform-origin: bottom center;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35),
              0 -7px 16px rgba(0, 0, 0, 0.2);
}
.curio-folder.is-open .curio-folder-back { box-shadow: 0 16px 30px rgba(0, 0, 0, 0.38); }
.curio-folder.is-open .curio-leaf { bottom: 88px; }

/* The shut folders step back so the open one reads as the one in hand. */
.curio-folder:not(.is-open) .curio-folder-face { opacity: 0.72; }

/* The flap and the sheets are the moving parts, so they carry the easing
   rather than the whole face. */
.curio-folder-front,
.curio-leaf,
.curio-folder-face { transition: transform 260ms cubic-bezier(0.2, 0.7, 0.3, 1),
                                 bottom 260ms cubic-bezier(0.2, 0.7, 0.3, 1),
                                 opacity 200ms ease; }
@media (prefers-reduced-motion: reduce) {
  .curio-folder-front,
  .curio-leaf,
  .curio-folder-face { transition: none; }
}

/* Shut, the cards are not on the page at all: they are inside the folder.
   The script sets the hidden attribute, so nothing under a shut folder is
   tabbable or readable by a screen reader. .curio-stack sets display: grid,
   which would otherwise beat the user agent's rule for [hidden]. */
.curio-stack[hidden] { display: none; }

/* The cards, once the folder is open. */
.curio-stack {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 32px 28px;
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: none;
}
.curio-stack > .curio-slot {
  animation: rise var(--anim-rise-duration) var(--anim-rise-ease) both;
}
/* Dealt out rather than appearing at once. Capped at six so a long month
   does not end with a card arriving a second and a half late. */
.curio-stack > .curio-slot:nth-child(1) { animation-delay: 0ms; }
.curio-stack > .curio-slot:nth-child(2) { animation-delay: 45ms; }
.curio-stack > .curio-slot:nth-child(3) { animation-delay: 90ms; }
.curio-stack > .curio-slot:nth-child(4) { animation-delay: 135ms; }
.curio-stack > .curio-slot:nth-child(5) { animation-delay: 180ms; }
.curio-stack > .curio-slot:nth-child(n + 6) { animation-delay: 225ms; }

@media (max-width: 1000px) {
  .curio-stack { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
  .curio-stack { grid-template-columns: minmax(0, 1fr); }
  /* The rail runs to both page edges so it reads as something you swipe
     rather than a row that happens to be clipped. The negative margin is
     paid back as padding, which keeps the first folder on the text's left
     edge and lets the last one scroll clear of the right one. */
  .curio-rail {
    margin-inline: calc(var(--pad-page) * -1);
    padding-inline: var(--pad-page);
    scroll-padding-inline: var(--pad-page);
  }
  /* The bleed already pays for the flap on this side; only the lift needs
     covering. */
  /* The face carries the width too, not just the flex basis: a flex item's
     min-width is auto, so the li cannot go narrower than the 300px button
     inside it however small the basis is set. */
  .curio-folder { flex-basis: 232px; }
  .curio-folder-face { width: 232px; height: 168px; }
  .curio-folder-label { left: 18px; bottom: 18px; }
  .curio-leaf { height: 74px; bottom: 58px; }
  .curio-folder.is-open .curio-leaf { bottom: 70px; }
}

.curio-card {
  position: relative;
  /* --card and --tilt are NOT declared here on purpose. They are set per
     slot below and inherit down; declaring defaults on the card itself
     shadows the inherited value and every card comes out identical. */
  --ink: #2b2b28;
  --stamp: #9c3b2e;
  background: var(--card);
  color: var(--ink);
  padding: 22px 22px 62px;
  border-radius: 2px;   /* card stock is cut, not rounded */
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  /* Ruled like a card, from the top down, so the text sits on the lines. */
  background-image: repeating-linear-gradient(
    to bottom,
    transparent 0 25px,
    rgba(43, 43, 40, 0.09) 25px 26px);
  background-position: 0 34px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25), 0 8px 20px rgba(0, 0, 0, 0.18);
  transform: rotate(var(--tilt));
  transition: transform 0.3s var(--anim-rise-ease, ease), box-shadow 0.3s ease;
}

/* Cards sit slightly out of true, and no two the same. Cycling on nth-child
   rather than randomising keeps it stable between builds — a tilt that
   changes on every deploy reads as a bug, not as character. Three tones of
   stock as well: a real drawer is never one batch of card. */
.curio-slot {
  display: block;
  /* Fallbacks, in case the cycle below ever misses. */
  --card: #efece2;
  --tilt: 0deg;
}
/* Set on the slot so they inherit down: the card is its only child, so
   nth-child on the card itself would never vary. */
.curio-slot:nth-child(4n + 1) { --tilt: -0.9deg; --card: #efece2; }
.curio-slot:nth-child(4n + 2) { --tilt:  0.7deg; --card: #ece7d9; }
.curio-slot:nth-child(4n + 3) { --tilt: -0.4deg; --card: #f1eee6; }
.curio-slot:nth-child(4n + 4) { --tilt:  1.1deg; --card: #eae5d6; }

/* Picking one up straightens it. */
@media (hover: hover) and (pointer: fine) {
  .curio-slot:hover .curio-card {
    transform: rotate(0deg) translateY(-3px);
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.28), 0 14px 30px rgba(0, 0, 0, 0.26);
  }
}

/* Call number, top right, where a librarian would pencil it. */
.curio-call {
  position: absolute;
  top: 14px;
  right: 16px;
  margin: 0;
  font-size: 10px;
  letter-spacing: 0.14em;
  opacity: 0.45;
}

.curio-kind {
  margin: 0;
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  opacity: 0.6;
}

.curio-head { margin: 10px 0 0; }
.curio-title {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 400;
  line-height: 1.3;
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid rgba(43, 43, 40, 0.3);
}
@media (hover: hover) and (pointer: fine) {
  a.curio-title:hover { border-bottom-color: var(--ink); }
}

/* A plate tipped onto the card. Small and square, top left, with a white
   border and a shadow so it reads as something pasted on rather than
   printed into the stock — which is how an illustration got onto a
   catalogue card. The text wraps around it. */
.curio-plate {
  float: left;
  width: 120px;
  height: 120px;
  margin: 2px 16px 10px 0;
  padding: 4px;
  background: #fbfaf6;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  transform: rotate(-1.5deg);
}
.curio-plate img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* The source line clears the plate so the two do not collide on the first
   line; everything after wraps around it as normal. */
.curio-slot--plated .curio-kind { margin-left: 136px; }

/* The material designation, set on the title's line as a cataloguer would
   run it: same size as the card's body type, not the display face, so it
   reads as an annotation on the title rather than part of it. */
.curio-gmd {
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 11px;
  letter-spacing: 0.02em;
  opacity: 0.55;
  white-space: nowrap;
}

.curio-note {
  margin: 12px 0 0;
  font-size: 12px;
  line-height: 26px;   /* on the rule, not between two of them */
  opacity: 0.85;
  max-width: none;
}

/* The date stamp — what a library actually pressed onto a card, rather than
   the word READ, which is the obvious move and reads as one. Off-square and
   not quite solid: a stamp is pressed by hand onto a pad that is drying. */
.curio-stamp {
  position: absolute;
  right: 16px;
  bottom: 18px;
  padding: 4px 9px;
  border: 2px solid var(--stamp);
  border-radius: 2px;
  box-shadow: inset 0 0 0 1px var(--stamp);
  color: var(--stamp);
  font-family: ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.14em;
  transform: rotate(-6deg);
  opacity: 0.72;
}

/* Holes. A card that has been through a drawer has more than one: the rod
   hole it was filed on, plus whatever it picked up from an earlier life in
   a binder or a different tray. Punched through to the page colour with an
   inset shadow, so they read as holes rather than painted dots — that is
   the whole trick, and it is why they must sit over the card's own
   background rather than over text.
   Positions cycle per slot so no two cards were filed the same way. */
.curio-punch {
  position: absolute;
  border-radius: 50%;
  background: var(--color-bg);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
  pointer-events: none;
}

/* The rod hole: the big one, along the bottom, always present. */
.curio-punch--rod {
  bottom: 22px;
  left: var(--rod-x, 34px);
  width: 14px;
  height: 14px;
}

/* Two smaller, older holes. Set to display: none by default so a card only
   shows the ones its slot gives it — three identical cards with three
   identical extra holes would be worse than none. */
.curio-punch--a,
.curio-punch--b { display: none; width: 8px; height: 8px; }

.curio-slot:nth-child(4n + 1) { --rod-x: 30px; }
.curio-slot:nth-child(4n + 1) .curio-punch--a { display: block; top: 16px; left: 12px; }
/* Kept left of centre: right: 96px put it inside the date stamp. */
.curio-slot:nth-child(4n + 1) .curio-punch--b { display: block; bottom: 26px; left: 116px; width: 6px; height: 6px; }

.curio-slot:nth-child(4n + 2) { --rod-x: 44px; }
.curio-slot:nth-child(4n + 2) .curio-punch--a { display: block; bottom: 24px; left: 74px; width: 6px; height: 6px; }

.curio-slot:nth-child(4n + 3) { --rod-x: 26px; }
.curio-slot:nth-child(4n + 3) .curio-punch--a { display: block; top: 14px; right: 108px; }
.curio-slot:nth-child(4n + 3) .curio-punch--b { display: block; bottom: 25px; left: 62px; width: 5px; height: 5px; }

.curio-slot:nth-child(4n + 4) { --rod-x: 38px; }
.curio-slot:nth-child(4n + 4) .curio-punch--a { display: block; bottom: 27px; left: 66px; width: 7px; height: 7px; }

@media (max-width: 700px) {
  /* Straightened on one column: tilted full-width cards stack into a
     zigzag, which reads as broken rather than as character. */
  .curio-stack > .curio-slot { --tilt: 0deg; }
}

/* Results block. The figures are the point, so they are set in the display
   face at heading size and left inline in their own sentence — pulling
   them onto their own line would have meant rewriting the sentences, and
   these are the author's words. Rules between rows, not bullets: this is
   a table of results, not a list of items. */
.story-body .stats {
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-tile);
  padding: 4px 32px;
  margin: 48px 0;
}
.story-body .stats ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
/* Two columns rather than one run-on line. The figure was set inline in the
   display face at 34px against 19px body sans, which put two typefaces on
   one baseline with nothing between them: "4 ways", "100% of spacing". A
   number and its sentence are two different things and the layout should
   say so.
   The column sizes to the widest figure and the figures sit flush right in
   it, so the gap between a number and its sentence is the same on every row
   and the sentences still start at a common x. Left-aligned in a fixed
   column, "4" sat a mile from its text while "100%" nearly touched its own,
   which is what read as broken alignment. */
.story-body .stats li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: baseline;
  column-gap: 22px;
  font-size: 18px;
  line-height: 1.55;
  padding: 26px 0;
  border-top: 1px solid var(--color-border);
  color: var(--color-muted);
}
.story-body .stats li:first-child { border-top: 0; }
.story-body .stats strong {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 46px;
  line-height: 1;
  letter-spacing: -0.01em;
  color: var(--color-text);
  justify-self: end;
  /* The display face at line-height 1 puts its baseline a few pixels above
     where the body text's sits. Nudged back onto it. */
  transform: translateY(4px);
}

/* Below the article measure the two columns stop earning their keep: a
   116px gutter against a 40-character line leaves the sentences ragged. */
@media (max-width: 640px) {
  .story-body .stats li {
    grid-template-columns: minmax(0, 1fr);
    row-gap: 6px;
    padding: 20px 0;
  }
  .story-body .stats strong { font-size: 38px; justify-self: start; transform: none; }
}

/* A callout is a highlighted aside — usually the article's takeaways — so
   it gets a surface of its own rather than only an outline. --color-card
   is the same fill the Recent card uses: lifted off the page in the dark
   theme, distinct from it in the light one. */
.story-body .callout {
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-tile);
  padding: 32px;
  margin: 48px 0;
}

/* The first and last children arrive with the body's own margins — 56px
   above a heading, 40px below a paragraph — which stacked on top of the
   padding and left a trench at each end of the box. The padding is the
   box's spacing; the content's own margins are not wanted at the edges. */
.story-body .callout > :first-child { margin-top: 0; }
.story-body .callout > :last-child { margin-bottom: 0; }

/* Inside the box the pairs sit closer than they would in running text: a
   heading and its line are one item, and 32px separates items rather than
   the 40 + 56 = 96px the body rhythm would give. */
.story-body .callout h2 { margin-bottom: 24px; }
.story-body .callout h3 { margin-top: 32px; }
.story-body .callout p { margin-bottom: 0; }

.story-body .pull-quote {
  font-family: var(--font-sans);
  font-size: 24px;
  line-height: 1.3em; /* 31.2px */
  font-style: normal;
  font-weight: 500;
  color: var(--color-text);
  border: none;
  margin: 40px 0;
  padding: 0;
}

/* Next/previous article nav — reuses the .story-list/.story-item/
   .story-link/.story-arrow pattern from the homepage Recent list and
   Play page rather than a bespoke component. Absent entirely (no empty
   card) when there is no adjacent article on either side. */
.story-nav {
  max-width: 100%;
  margin: 32px auto 0;
  background: var(--color-card);
  border-radius: var(--radius-tile);
  padding: 16px 48px;
}

@media (max-width: 768px) {
  /* The cap has to reach the media as well as the box. The box was capped
     to 40vh here while the img/video kept the 60vh from the base rule, so
     object-fit computed its crop against a 60vh box and overflow: hidden
     then cut the bottom third off — measured 455px of media inside a 303px
     window at 500x758, i.e. 152px lost off the bottom rather than an even
     crop. One custom property now drives both. */
  .story-cover { --cover-max-h: 40vh; }

  /* No 28px inset. Neither of these has a background of its own any more —
     .story-meta-card is padding: 0 on desktop and .story-body is 8px 0 0 —
     so this was pure lost width: at a 500px viewport it left the title and
     the running text in a 397px column while the nav pill, the footer and
     .story-nav (which does have a surface) all ran the full 453px. The
     article now shares the page's own gutter. */
  .story-meta-table { grid-template-columns: repeat(2, auto); }
  .story-meta-table > *:nth-child(4n + 3) { padding-left: 0; }
  .story-body h2 { font-size: 28px; }
  .story-body h3 { font-size: 22px; }
  .story-body .pull-quote { font-size: 20px; }
  .story-nav { padding: 8px 28px; }
}

/* Reduced motion (task-8c, extended in task-20): the story-card video
   cover was the first autoplaying, looping motion this site shipped, so
   this was the first prefers-reduced-motion rule this stylesheet needed.
   CSS cannot stop an already-started <video autoplay> from playing —
   that's handled by a small inline script in templates/stories.js, gated
   on the same query, which strips autoplay/loop and pauses the element.
   The story-card hover-lift removal below is defence in depth on top of
   that.

   task-20 adds the global suppression the brief calls for: every CSS
   animation/transition site-wide is cut to effectively-instant. The p5
   sketch (movements.js) and the hover-effects.js keyword animations are
   JS-driven and are guarded separately, in their own files, under the
   same media query. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  .story-tile-link:hover {
    transform: none;
  }
}


/* animations.css */
/* ─── Line wrapper for mask-reveal-up ─── */
.anim-line { display: block; }

/* ─── Easing tokens ─── */
:root {
  --ease-sig:    cubic-bezier(0.05, 0.7, 0.1, 1);
  --ease-snap:   cubic-bezier(0.22, 1, 0.36, 1);
  --ease-scale:  cubic-bezier(0.32, 0.72, 0, 1);
  --ease-reveal: cubic-bezier(0.16, 1, 0.3, 1);  /* expressive ease-out: fast start, graceful stop */

  /* ── Standard entrance: fade + blur + rise.
        One effect, one set of tokens. Tune here, everywhere follows. ── */
  --anim-rise-y:        24px;
  --anim-rise-blur:     6px;
  --anim-rise-duration: 560ms;
  --anim-rise-ease:     var(--ease-reveal);

  /* ── Card entrance: the standard rise plus a 3D tilt, matching the
        original Framer treatment (perspective 5000px, rotateX 15deg).
        Cards are large surfaces, so the tilt reads as the surface
        settling into place rather than as motion for its own sake. ── */
  --anim-tilt-perspective: 5000px;
  --anim-tilt-rotate:      15deg;
  --anim-tilt-y:           30px;
}

/* Entrance elements are never a scroll anchor.
   Chrome saves a scroll anchor with the history entry, so on reload it
   restores the position by matching an element back to where it sat when
   the page was left. Every animated element is left at translateY(0) but
   comes back at translateY(24px), so matching it scrolled the page down
   by exactly the rise distance — and because that offset was then saved
   in turn, each further reload added another 24px. Nothing on screen had
   moved; the anchor had. Excluding them leaves the offset to be restored
   as a plain number, which is right for a page that never lies about
   where its content is once the entrance has finished. */
html.js [data-anim],
html.js [data-anim-group],
html.js .nav-blur,
html.js .nav-pill,
html.js .nav-social { overflow-anchor: none; }

/* ─── Initial hidden states (only when JS is active) ─── */

html.js .nav-blur,
html.js .nav-pill,
html.js .nav-social {
  opacity: 0;
  transform: translateY(var(--anim-rise-y));
  filter: blur(var(--anim-rise-blur));
  will-change: transform, opacity, filter;
}

html.js [data-anim="fade-in"] {
  opacity: 0;
}

html.js [data-anim="rise-tilt"] {
  opacity: 0;
  transform: perspective(var(--anim-tilt-perspective)) translateY(var(--anim-tilt-y)) rotateX(var(--anim-tilt-rotate));
  filter: blur(var(--anim-rise-blur));
  transform-origin: 50% 0%;
  will-change: transform, opacity, filter;
}

html.js [data-anim="fade-up"],
html.js [data-anim="rise"],
html.js [data-anim="micro-scale-fade"],
html.js [data-anim="mask-reveal-up"] .anim-line {
  opacity: 0;
  transform: translateY(var(--anim-rise-y));
  filter: blur(var(--anim-rise-blur));
  will-change: transform, opacity, filter;
}


/* Nav already entered earlier this session: show it immediately rather than
   replaying the entrance on every page navigation. */
html.nav-instant .nav-blur,
html.nav-instant .nav-pill,
html.nav-instant .nav-social {
  opacity: 1;
  transform: none;
  filter: none;
  animation: none !important;
  will-change: auto;
}

/* ─── Keyframes ─── */

@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes riseTilt {
  from {
    opacity: 0;
    transform: perspective(var(--anim-tilt-perspective)) translateY(var(--anim-tilt-y)) rotateX(var(--anim-tilt-rotate));
    filter: blur(var(--anim-rise-blur));
  }
  to {
    opacity: 1;
    transform: perspective(var(--anim-tilt-perspective)) translateY(0) rotateX(0deg);
    filter: blur(0);
  }
}

@keyframes rise {
  from { opacity: 0; transform: translateY(var(--anim-rise-y)); filter: blur(var(--anim-rise-blur)); }
  to   { opacity: 1; transform: translateY(0);                  filter: blur(0); }
}


html.js [data-anim-group] {
  opacity: 0;
}

/* ─── Activated states (JS adds .is-visible) ─── */

[data-anim-group].is-visible {
  animation: fadeIn 400ms var(--ease-sig) both;
}

/* Nav enters on the same standard as everything else. The bar, the pills
   and the social icons animate as one unit, so it reads as a single object
   arriving rather than parts fading in independently. */
.nav-blur.is-visible,
.nav-pill.is-visible,
.nav-social.is-visible {
  animation: rise var(--anim-rise-duration) var(--anim-rise-ease) both;
}

[data-anim="fade-in"].is-visible {
  animation: fadeIn 400ms var(--ease-sig) both;
}

/* The standard entrance. Every element uses the same curve, distance and
   blur; mask-reveal-up differs only in that animations.js staggers its
   per-line children. */
[data-anim="rise-tilt"].is-visible {
  animation: riseTilt var(--anim-rise-duration) var(--anim-rise-ease) both;
}

[data-anim="fade-up"].is-visible,
[data-anim="rise"].is-visible,
[data-anim="micro-scale-fade"].is-visible,
[data-anim="mask-reveal-up"].is-visible .anim-line {
  animation: rise var(--anim-rise-duration) var(--anim-rise-ease) both;
}


/* hover-effects.css */
/* ── Base ──────────────────────────────────────────────────────── */
[data-hover] {
  display: inline-block;
  position: relative;
  cursor: pointer;
  vertical-align: baseline;
}

/* ── Design — Figma selection box ──────────────────────────────── */
[data-hover="design"] {
  color: inherit;
  transition: color 0.15s ease;
  white-space: nowrap;
}
[data-hover="design"]:hover,
[data-hover="design"].dg-dragging { color: #18a0fb; }

[data-hover="design"] .hw-inner {
  display: inline-block;
  position: relative;
  vertical-align: baseline;
}
[data-hover="design"] .sel-border {
  position: absolute;
  inset: -5px -4px;
  border: 1.5px solid #18a0fb;
  border-radius: 1px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
}
[data-hover="design"]:hover .sel-border,
[data-hover="design"].dg-dragging .sel-border { opacity: 1; }

[data-hover="design"] .dim-label {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  background: #18a0fb;
  color: white;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  padding: 2px 7px;
  border-radius: 3px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
  letter-spacing: 0.04em;
  z-index: 20;
}
[data-hover="design"]:hover .dim-label,
[data-hover="design"].dg-dragging .dim-label { opacity: 1; }

[data-hover="design"] .handle {
  position: absolute;
  width: 8px;
  height: 8px;
  background: white;
  border: 1.5px solid #18a0fb;
  border-radius: 1px;
  opacity: 0;
  transition: opacity 0.15s ease;
  z-index: 10;
}
[data-hover="design"]:hover .handle,
[data-hover="design"].dg-dragging .handle { opacity: 1; }

[data-hover="design"] .handle.tl { top: -9px;    left: -8px;   cursor: nwse-resize; }
[data-hover="design"] .handle.tr { top: -9px;    right: -8px;  cursor: nesw-resize; }
[data-hover="design"] .handle.bl { bottom: -9px; left: -8px;   cursor: nesw-resize; }
[data-hover="design"] .handle.br { bottom: -9px; right: -8px;  cursor: nwse-resize; }
[data-hover="design"] .handle.tm { top: -9px;    left: 50%; transform: translateX(-50%); cursor: ns-resize; }
[data-hover="design"] .handle.bm { bottom: -9px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
[data-hover="design"] .handle.lm { left: -8px;   top: 50%;  transform: translateY(-50%); cursor: ew-resize; }
[data-hover="design"] .handle.rm { right: -8px;  top: 50%;  transform: translateY(-50%); cursor: ew-resize; }

/* ── Art — emoji cycle ─────────────────────────────────────────── */
[data-hover="art"] .hw-text {
  display: inline-block;
  transition: opacity 0.15s ease, transform 0.15s ease;
}
[data-hover="art"]:hover .hw-text {
  opacity: 0;
  transform: scale(0.75);
}
[data-hover="art"] .hw-overlay {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) scale(0.5);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.24s cubic-bezier(0.34, 1.5, 0.64, 1);
  font-style: normal;
}
[data-hover="art"]:hover .hw-overlay {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1.2);
}

/* ── Music — SoundCloud popover ────────────────────────────────── */
[data-hover="music"] {
  vertical-align: baseline;
  overflow: visible;
  -webkit-background-clip: text;
  background-clip: text;
  transition: -webkit-text-fill-color 0.2s ease;
}
[data-hover="music"]:hover {
  background-image: linear-gradient(90deg, #ff0055, #ff8800, #ffee00, #00e676, #00aaff, #cc44ff, #ff0055);
  background-size: 250% 100%;
  -webkit-text-fill-color: transparent;
  animation: music-rainbow 1.4s linear infinite;
}
[data-hover="music"]:not(:hover) {
  background-image: none;
  -webkit-text-fill-color: inherit;
  animation: none;
}
@keyframes music-rainbow {
  from { background-position: 0% 50%; }
  to   { background-position: 250% 50%; }
}

[data-hover="music"] .sc-popover {
  position: absolute;
  bottom: calc(100% + 12px);
  left: 50%;
  transform: translateX(-50%) translateY(8px);
  width: 300px;
  background: rgba(18, 20, 22, 0.96);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid rgba(253, 251, 242, 0.1);
  border-radius: 16px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s ease, transform 0.22s ease;
  z-index: 200;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
[data-hover="music"]:hover .sc-popover {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(-50%) translateY(0);
}
[data-hover="music"] .sc-popover iframe {
  display: block;
  width: 100%;
  height: 166px;
  border: none;
}

/* ── Design Systems — Lego bricks ─────────────────────────────── */
[data-hover="design-systems"] { overflow: visible; }
[data-hover="design-systems"] .hw-text {
  display: inline-block;
  transition: opacity 0.15s ease;
}
[data-hover="design-systems"]:hover .hw-text { opacity: 0; }
[data-hover="design-systems"] .hw-overlay {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
  overflow: visible;
}
[data-hover="design-systems"]:hover .hw-overlay { opacity: 1; }

/* ── AI — blurred blob + scattered model logos ────────────────── */
/* isolate: both layers ride at z-index -1 so they paint above this span's
   own (empty) background but behind its text, and nothing can escape into
   the page's stacking order. The word itself is never wrapped or replaced,
   so hovering cannot reflow the paragraph. */
[data-hover="ai"] {
  /* Dark theme: a near-black blob under cream marks. */
  --ai-core:        rgba(6, 7, 9, 0.95);
  --ai-core-mid:    rgba(6, 7, 9, 0.78);
  --ai-core-edge:   rgba(6, 7, 9, 0);
  --ai-aura:        rgba(253, 251, 242, 0.16);
  --ai-aura-mid:    rgba(253, 251, 242, 0.1);
  --ai-aura-edge:   rgba(253, 251, 242, 0);
  --ai-mark:        rgba(253, 251, 242, 0.92);
  --ai-mark-halo:   rgba(6, 7, 9, 0.85);
  --ai-word:        rgb(253, 251, 242);
  --ai-word-halo:   rgba(8, 10, 12, 0.7);

  isolation: isolate;
  color: inherit;
  transition: color 0.18s ease, text-shadow 0.18s ease;
}

/* Light theme: the whole thing inverts — a white blob under ink-dark marks. */
[data-theme="light"] [data-hover="ai"] {
  --ai-core:        rgba(255, 255, 255, 0.96);
  --ai-core-mid:    rgba(255, 255, 255, 0.82);
  --ai-core-edge:   rgba(255, 255, 255, 0);
  --ai-aura:        rgba(32, 33, 36, 0.16);
  --ai-aura-mid:    rgba(32, 33, 36, 0.1);
  --ai-aura-edge:   rgba(32, 33, 36, 0);
  --ai-mark:        rgba(32, 33, 36, 0.9);
  --ai-mark-halo:   rgba(255, 255, 255, 0.9);
  --ai-word:        rgb(32, 33, 36);
  --ai-word-halo:   rgba(255, 255, 255, 0.85);
}
@media (prefers-color-scheme: light) {
  /* Mirrors the light block above for visitors on the system default —
     same pattern global.css uses for its own tokens. */
  :root:not([data-theme="dark"]) [data-hover="ai"] {
    --ai-core:        rgba(255, 255, 255, 0.96);
    --ai-core-mid:    rgba(255, 255, 255, 0.82);
    --ai-core-edge:   rgba(255, 255, 255, 0);
    --ai-aura:        rgba(32, 33, 36, 0.16);
    --ai-aura-mid:    rgba(32, 33, 36, 0.1);
    --ai-aura-edge:   rgba(32, 33, 36, 0);
    --ai-mark:        rgba(32, 33, 36, 0.9);
    --ai-mark-halo:   rgba(255, 255, 255, 0.9);
    --ai-word:        rgb(32, 33, 36);
    --ai-word-halo:   rgba(255, 255, 255, 0.85);
  }
}

[data-hover="ai"]:hover {
  color: var(--ai-word);
  text-shadow: 0 0 10px var(--ai-word-halo);
}

[data-hover="ai"] .ai-blob {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: -1;
  width: 212px;
  height: 104px;
  margin: -52px 0 0 -106px;
  border-radius: 50%;
  pointer-events: none;
  /* Solid core in front, a faint counter-toned aura behind it — the aura is
     what keeps the shape's edge legible against a ground of its own value. */
  background:
    radial-gradient(
      ellipse at 50% 50%,
      var(--ai-core) 0%,
      var(--ai-core-mid) 38%,
      var(--ai-core-edge) 68%
    ),
    radial-gradient(
      ellipse at 50% 50%,
      var(--ai-aura) 0%,
      var(--ai-aura-mid) 58%,
      var(--ai-aura-edge) 80%
    );
  filter: blur(11px);
  opacity: 0;
  transform: scale(0.66);
  transition: opacity 0.22s ease, transform 0.34s cubic-bezier(0.34, 1.4, 0.64, 1);
}
[data-hover="ai"]:hover .ai-blob { opacity: 1; transform: scale(1); }

[data-hover="ai"] .ai-logos {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: -1;
  width: 0;
  height: 0;
  pointer-events: none;
}
[data-hover="ai"] .ai-logo {
  position: absolute;
  left: 0;
  top: 0;
  color: var(--ai-mark);
  /* Marks that overhang the blob land on a ground close to their own value —
     the halo is what keeps their edges. */
  filter: drop-shadow(0 0 3px var(--ai-mark-halo));
  opacity: 0;
  transform: translate(-50%, -50%) translate(var(--x), var(--y)) rotate(var(--r)) scale(0.35);
  transition: opacity 0.18s ease var(--d, 0s),
              transform 0.36s cubic-bezier(0.34, 1.5, 0.64, 1) var(--d, 0s);
}
[data-hover="ai"] .ai-logo svg { display: block; }
[data-hover="ai"]:hover .ai-logo {
  opacity: 1;
  transform: translate(-50%, -50%) translate(var(--x), var(--y)) rotate(var(--r)) scale(var(--s, 1));
}
[data-hover="ai"]:hover .ai-logo svg {
  animation: ai-drift var(--f, 3.4s) ease-in-out var(--d, 0s) infinite alternate;
}
@keyframes ai-drift {
  from { transform: translateY(1.5px) rotate(-2.5deg); }
  to   { transform: translateY(-1.5px) rotate(2.5deg); }
}

/* Reduced motion: same static composition, it just appears. */
[data-hover="ai"].ai-static .ai-blob,
[data-hover="ai"].ai-static .ai-logo { transition: none; }
[data-hover="ai"].ai-static .ai-logo {
  transform: translate(-50%, -50%) translate(var(--x), var(--y)) rotate(var(--r)) scale(var(--s, 1));
  opacity: 0;
}
[data-hover="ai"].ai-static:hover .ai-logo { opacity: 1; }
[data-hover="ai"].ai-static .ai-blob { transform: scale(1); }
[data-hover="ai"].ai-static:hover .ai-logo svg { animation: none; }

@media (prefers-reduced-motion: reduce) {
  [data-hover="ai"],
  [data-hover="ai"] .ai-blob,
  [data-hover="ai"] .ai-logo { transition: none; }
  [data-hover="ai"]:hover .ai-logo svg { animation: none; }
}
