/* ---------- Design tokens (see designrules.md) ---------- */
:root {
  --bg: #000000;
  --fg: #f1f1f1;
  --fg-dim: rgba(241, 241, 241, 0.68);
  --accent: #ffffff;
  --border: rgba(255, 255, 255, 0.14);
  /* One font, one weight, site-wide — see designrules.md */
  --font-sans: "Montserrat", sans-serif;
  /* Montserrat, twice made a fifth heavier than Thin. Measured in Chromium,
     the stem of an "l" is 8.0 units at weight 100, 9.6 at 130 and 11.4 at
     165 — 1.2x each time, so the number is read off the font, not guessed. */
  --fw: 165;
  /* The single exception: the AKI GATO wordmark */
  --font-brand: "Century Gothic", CenturyGothic, AppleGothic, "Questrial", sans-serif;
  --tracking-wordmark: clamp(0.15em, 2vw, 0.4em);

  /* one type scale, used everywhere */
  --fs-1: clamp(2.4rem, 1.9rem + 2.2vw, 4.5rem);
  --fs-2: clamp(1.6rem, 1.4rem + 1vw, 2.4rem);
  --fs-3: clamp(1.25rem, 1.15rem + 0.5vw, 1.6rem);
  --fs-4: clamp(1.05rem, 1rem + 0.25vw, 1.2rem);
  --fs-body: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --fs-small: clamp(0.78rem, 0.75rem + 0.15vw, 0.9rem);

  --space-para: clamp(1rem, 0.85rem + 0.5vw, 1.5rem);
  /* The single interval an essay uses between a block of text and a figure,
     above and below alike. */
  --space-figure: clamp(2.75rem, 6vw, 4.25rem);
  --justify-tracking: 0.01em;
  --measure: clamp(45ch, 60vw, 65ch);

  --nav-h: 72px;
  /* The tool ticker's measured height. js/app.js re-measures it on load and
     on resize (the same runtime-token pattern as --sbw), so this value is
     only the starting guess. */
  --marquee-h: 48px;
  /* Everything fixed or sticky above the page. The home intro used to size
     itself against --nav-h alone and then ALSO carry a margin-top of --nav-h,
     so it double-counted the nav and ignored the ticker entirely — running
     120px past the fold with its wordmark 156px below optical centre. */
  --chrome-h: calc(var(--nav-h) + var(--marquee-h));
  /* The shell's own box. Written out four times as a bare 1400px, which meant
     #app-content and the editorial blocks that re-apply it could drift apart
     silently. One token now. */
  --content-max: 1400px;
  --dur: 0.45s;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Glow containment. A box-shadow reaches (blur / 2 + spread) beyond its
     box, so --glow-blur of 30px with no spread reaches 15px — comfortably
     inside the 20px of padding every hoverable item reserves. Change one,
     re-check the other. */
  --glow-room: 20px;
  --glow-blur: 30px;
}

/* The straight inversion of the dark theme — for pages whose work is itself
   white, where black would fight the slides instead of framing them. */
[data-theme="light"] {
  --bg: #ffffff;
  --fg: #121212;
  --fg-dim: rgba(18, 18, 18, 0.68);
  --accent: #000000;
  --border: rgba(0, 0, 0, 0.14);
}

/* Gem-bot's slides are drawn on a flat #e0e0e0 — sampled at all four corners
   of every sheet — so the page takes the same ground and the slides sit on it
   without an edge. On that grey the usual dim ink falls to about 3.4:1, which
   is why the copy looked washed out; it is carried to 0.82 (roughly 8:1) and
   the rules step up with it. */
[data-theme="gembot"] {
  --bg: #e0e0e0;
  --fg: #121212;
  --fg-dim: rgba(18, 18, 18, 0.86);
  --accent: #000000;
  --border: rgba(0, 0, 0, 0.22);
}

/* Sersery's own palette, measured off the live page: the content ground is
   cream (#FDF0E9 — painted on an inner wrapper there, not on body), so the
   nav and footer invert to dark type. */
[data-theme="sersery"] {
  --bg: #fdf0e9;
  --fg: #2d2926;
  /* Site chrome only — Sersery's own copy uses its measured taupe. 0.65 put
     the tool ticker at 4.4:1 on the cream, a hair under AA; 0.72 is ~5:1. */
  --fg-dim: rgba(45, 41, 38, 0.78);
  --accent: #c48b76;
  --border: rgba(45, 41, 38, 0.14);
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }

body {
  margin: 0;
  /* Widow control for every piece of text on the site, not only the
     paragraphs that asked for it. Rules that want a balanced block rather
     than a tidy last line (headings, the data callouts) say so themselves
     and outrank this. */
  text-wrap: pretty;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-sans);
  font-weight: var(--fw);
  font-size: var(--fs-body);
  line-height: 1.6;
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
  overflow-x: hidden;
}

/* Headings are Montserrat at --fw like everything else — the wordmark below is
   the single exception. Without this an h1/h2 keeps the browser's default
   bold, and because the Montserrat link loads only 100–500 the browser has
   no real 700 to use and synthesises one: the smeared heavy type this rule
   exists to prevent. Class-level rules still win, so anything that means to
   break from thin can. */
h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-sans);
  font-weight: var(--fw);
  /* A heading is short display text: even line lengths read better than a
     tidy last line, which is what `pretty` optimises for. */
  text-wrap: balance;
}

/* Form controls do NOT inherit type from the page — the browser gives every
   button, input, textarea and select the system font at weight 400. The
   Tailwind CDN used to paper over this with its preflight reset; it is gone,
   so the reset is stated here instead. Without it the hamburger, the three
   lightbox arrows and every contact-field fell back to Arial 400 — heavier
   and a different face. One font, one weight means these too. */
button, input, textarea, select, optgroup {
  font-family: inherit;
  font-weight: inherit;
  font-size: inherit;
  line-height: inherit;
  color: inherit;
  letter-spacing: inherit;
}

/* The wordmark is the one place that breaks from Montserrat */
.wordmark {
  font-family: var(--font-brand);
  font-weight: 400;
  /* No outline. The rest of the type was thickened twice for readability;
     the wordmark is a mark, not something anyone has to read a paragraph
     of, and thin is what it is. It stays at its own single weight. */
  letter-spacing: var(--tracking-wordmark);
}

/* The skip link: off-screen until focused, then it lands top-left in the
   site’s own type — tracked, uppercase, no box behind it. */
.skip-link {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 2000;
  transform: translateY(-120%);
  padding: 0.9rem 1.25rem;
  background: var(--bg);
  color: var(--fg);
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  transition: transform 0.25s var(--ease);
}
.skip-link:focus,
.skip-link:focus-visible { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }

/* Present to a screen reader, absent to the eye. Used for the heading of a
   page whose title is carried by its picture. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ---------- Focus ----------
   Keyboard focus was left to the browser, which draws an orange ring —
   on a pure-black page set in Montserrat Thin. This is the same hairline
   the rest of the site uses for a rule, offset so it never touches the
   glyphs, and it brings the item to full opacity as well: colour alone is
   not a focus indicator (WCAG 1.4.1), and hover brightening while focus
   did nothing was the wrong way round.

   :focus-visible only — a mouse click never draws it. */
:focus-visible {
  outline: 1px solid var(--fg);
  outline-offset: 3px;
  opacity: 1;
}
/* An embedded frame (the YouTube player, the built pieces) takes focus
   into its own document without ever matching :focus-visible, so app.js
   marks it when it was reached by Tab. It gets the same hairline. */
iframe.is-kb-focus {
  outline: 1px solid var(--fg);
  outline-offset: 3px;
}
/* Nothing on the site relies on the default ring, so the fallback for
   browsers without :focus-visible stays the UA one rather than nothing. */

img, video { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }

/* ---------- Global custom cursor (site-wide, from the home intro) ---------- */
/* !important is deliberate here: this replaces the system pointer everywhere,
   overriding any element-level `cursor: pointer` declared elsewhere.
   Gated on `.has-custom-cursor`, which js/app.js adds only once the dot is
   actually mounted — stated unconditionally, a JS error or a blocked script
   left the visitor with no pointer at all and nothing to click with. */
.has-custom-cursor,
.has-custom-cursor *,
.has-custom-cursor *::before,
.has-custom-cursor *::after { cursor: none !important; }

/* Text fields keep their I-beam: the dot says nothing about where the caret
   will land, and the contact form is the one place that has to be typed in. */
.has-custom-cursor input,
.has-custom-cursor textarea,
.has-custom-cursor select { cursor: auto !important; }

#custom-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 8px;
  height: 8px;
  background: #fff;
  border-radius: 50%;
  pointer-events: none;
  z-index: 9999;
  box-shadow: 0 0 20px 2px rgba(255, 255, 255, 0.8);
  mix-blend-mode: difference;
  transition: transform 0.2s ease-out;
  transform: translate(-50%, -50%);
}

/* The cursor inverts with the page: a dark dot on light pages, so it stays
   visible instead of vanishing into white. */
[data-theme="light"] #custom-cursor,
[data-theme="sersery"] #custom-cursor {
  background: #121212;
  box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.35);
  mix-blend-mode: normal;
}

@media (hover: none) {
  #custom-cursor { display: none; }
  *, *::before, *::after { cursor: auto !important; }
}
@media (prefers-reduced-motion: reduce) {
  #custom-cursor { transition: none; }
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--dur);
  animation-timing-function: var(--ease);
}
::view-transition-old(root) { animation-name: dissolve-out; }
::view-transition-new(root) { animation-name: dissolve-in; }
@keyframes dissolve-out { to { opacity: 0; } }
@keyframes dissolve-in { from { opacity: 0; } }

/* manual crossfade fallback for browsers without View Transitions */
#app { transition: opacity var(--dur) var(--ease); }
#app.fade-out { opacity: 0; }
#app.fade-in { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  #app, ::view-transition-old(root), ::view-transition-new(root) { animation: none !important; transition: none !important; }
}

/* ---------- Nav ---------- */
.site-nav {
  position: fixed;
  inset: 0 0 auto 0;
  height: var(--nav-h);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 clamp(1rem, 3vw, 2.5rem);
  z-index: 100;
  background: var(--bg);
}

.brand {
  font-size: var(--fs-4);
  /* The way home, and the smallest target in the header at 27px against the
     menu toggle's 44. The header is 72px tall, so this is padding the layout
     already had room for and nothing moves. */
  display: inline-block;
  padding-block: 0.55rem;
  opacity: 0.88;
  transition: opacity 0.25s var(--ease);
}
.brand:hover, .brand.active { opacity: 1; }

.nav-links {
  display: flex;
  flex-wrap: nowrap;
  gap: clamp(0.6rem, 1.4vw, 1.5rem);
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--fs-small);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.nav-links a,
.nav-explore__btn {
  display: inline-block;
  white-space: nowrap;
  /* A 44px-tall target. Padding only — the row does not move because the
     header centres its contents. */
  padding: 0.7rem 0;
  transition: opacity 0.25s var(--ease);
  opacity: 0.68;
}
.nav-links a:hover,
.nav-links a[aria-current="page"],
.nav-explore__btn:hover,
.nav-explore__btn.is-current,
.nav-explore__btn[aria-expanded="true"] { opacity: 1; }
/* On a coloured ground the resting 0.6 falls under 4.5:1 — 4.48 on
   Gem-bot's grey, 3.83 on Sersery's cream — so there the links rest a step
   stronger. Dark pages and the white inversion already pass at 0.6. */
[data-theme="gembot"] .nav-links a,
[data-theme="gembot"] .nav-explore__btn,
[data-theme="gembot"] .footer-links a { opacity: 0.78; }
[data-theme="sersery"] .nav-links a,
[data-theme="sersery"] .nav-explore__btn,
[data-theme="sersery"] .footer-links a { opacity: 0.86; }
[data-theme="gembot"] .nav-links a:hover,
[data-theme="gembot"] .nav-links a[aria-current="page"],
[data-theme="gembot"] .nav-explore__btn:hover,
[data-theme="gembot"] .nav-explore__btn.is-current,
[data-theme="gembot"] .nav-explore__btn[aria-expanded="true"],
[data-theme="sersery"] .nav-links a:hover,
[data-theme="sersery"] .nav-links a[aria-current="page"],
[data-theme="sersery"] .nav-explore__btn:hover,
[data-theme="sersery"] .nav-explore__btn.is-current,
[data-theme="sersery"] .nav-explore__btn[aria-expanded="true"] { opacity: 1; }

/* EXPLORE — a button styled as one more word on the bar, with a hairline
   chevron that turns when the sections are open. */
.nav-explore { position: relative; }
.nav-explore__btn {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  background: none;
  border: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  cursor: pointer;
}
.nav-explore__btn::after {
  content: "";
  width: 0.4em;
  height: 0.4em;
  border-right: 1px solid currentColor;
  border-bottom: 1px solid currentColor;
  transform: translateY(-30%) rotate(45deg);
  transition: transform 0.25s var(--ease);
}
.nav-explore__btn[aria-expanded="true"]::after { transform: translateY(20%) rotate(-135deg); }

/* The six sections, dropped under Explore. Closed, the panel is
   visibility:hidden as well as transparent, so the keyboard cannot tab
   into links nobody can see. */
.nav-sections {
  position: absolute;
  top: 100%;
  left: -1.25rem;
  min-width: max-content;
  list-style: none;
  margin: 0;
  padding: 0.4rem 1.25rem 0.9rem;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translateY(-6px);
  transition: opacity 0.25s var(--ease), transform 0.25s var(--ease), visibility 0s linear 0.25s;
}
.nav-links.open .nav-sections {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: none;
  transition: opacity 0.25s var(--ease), transform 0.25s var(--ease), visibility 0s;
}
.nav-sections a { display: block; padding: 0.55rem 0; }

/* EXPLORE | CONTACT | CV — each word after the first set off by a hairline,
   and the CV lit brighter than the rest at rest. The bars are drawn, not
   typed, so a screen reader does not announce a stray "|". */
.nav-links .nav-split,
.footer-links .nav-cv {
  display: flex;
  align-items: center;
}
.nav-links .nav-split::before,
.footer-links .nav-cv::before {
  content: "";
  width: 1px;
  height: 1.1em;
  margin-right: clamp(0.6rem, 1.4vw, 1.5rem);
  background: currentColor;
  opacity: 0.5;
}
.nav-links .nav-cv a,
.footer-links .nav-cv a,
[data-theme] .nav-links .nav-cv a,
[data-theme] .footer-links .nav-cv a { opacity: 1; }

/* On a phone the panel spans the screen under the header, one section a
   line, each a full finger-height. */
@media (max-width: 600px) {
  .nav-explore { position: static; }
  .nav-sections {
    position: fixed;
    inset: var(--nav-h) 0 auto 0;
    min-width: 0;
    padding: 0.25rem 1.25rem 1.25rem;
    max-height: calc(100vh - var(--nav-h));
    overflow-y: auto;
  }
  .nav-sections a { padding: 0.85rem 0; }
  .nav-links.open .nav-sections { border-bottom: 1px solid var(--border); }
}
/* The smallest phones: the wordmark and the three words share 320px, so the
   tracking and the gaps tighten a step rather than anything wrapping. */
@media (max-width: 374px) {
  .site-nav .brand { letter-spacing: 0.2em; }
  .nav-links { gap: 0.45rem; letter-spacing: 0.04em; }
  .nav-links .nav-split::before { margin-right: 0.45rem; }
  .nav-explore__btn { gap: 0.4em; }
}

/* ---------- Marquee ---------- */
.marquee {
  overflow: hidden;
  white-space: nowrap;
  padding: 0.6rem 0;
  margin-top: var(--nav-h);
}
.marquee__track {
  display: inline-flex;
  align-items: center;
  gap: 2.5rem;
  animation: marquee 110s linear infinite;
}
.marquee__track span,
.marquee__track .marquee__tool {
  font-size: var(--fs-small);
  letter-spacing: 0.15em;
  color: var(--fg-dim);
}
/* A tool with credited work is clickable; the rest are plain labels. */
.marquee__tool {
  background: none;
  border: none;
  /* The tap area fills the ticker rather than hugging the 20px of type,
     which failed even the 24x24 floor of WCAG 2.5.8. Invisible: the button
     has no ground of its own. `outline: none` is gone — it was removing the
     only focus indicator these had. */
  padding-block: 0.85rem;
  padding-inline: 0;
  font-family: inherit;
  font-weight: inherit;
  text-transform: inherit;
  cursor: pointer;
  transition: color 0.3s var(--ease);
}
.marquee__tool:hover,
.marquee__tool:focus-visible { color: var(--fg); }
@keyframes marquee {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
  .marquee__track { animation: none; }
}

/* ---------- Layout frames (fluid, responsive) ---------- */
main#app-content {
  max-width: var(--content-max);
  margin-inline: auto;
  padding: clamp(2.5rem, 6vw, 6rem) clamp(1.25rem, 5vw, 3rem) clamp(4rem, 8vw, 7rem);
}

.hero {
  position: relative;
  isolation: isolate;
  min-height: clamp(280px, 50vh, 560px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 1rem;
  padding-block: clamp(2rem, 6vw, 4rem);
}
.hero__eyebrow, .hero__sub {
  font-size: var(--fs-small);
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
.hero__heading {
  font-size: var(--fs-1);
  letter-spacing: 0.12em;
  margin: 0;
  max-width: 100%;
}
/* A long single-word title ("NOMATTERWHAT") can't wrap, so on narrow screens
   the size and tracking come down far enough for it to fit. */
@media (max-width: 520px) {
  .hero__heading {
    font-size: clamp(1.35rem, 8.5vw, 2.4rem);
    letter-spacing: 0.05em;
  }
}
.hero__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  justify-content: center;
  margin-top: 0.5rem;
}
.hero__tags span, .toggle-pill {
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.4rem 1rem;
  font-size: var(--fs-small);
  letter-spacing: 0.1em;
  opacity: 0.64;
  transition: opacity 0.3s var(--ease), border-color 0.3s var(--ease);
}
.hero__tags span.active { opacity: 1; border-color: currentColor; }

.toggle-pill-group { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.toggle-pill-group button {
  border: none;
  border-radius: 0;
  padding: 0.4rem 1.1rem;
  font: inherit;
  font-size: var(--fs-small);
  letter-spacing: 0.1em;
  background: none;
  color: inherit;
  cursor: pointer;
  transition: background 0.25s var(--ease), color 0.25s var(--ease);
}
.toggle-pill-group button.active { background: var(--fg); color: var(--bg); }
.toggle-pill-group button:hover:not(.active) { background: color-mix(in srgb, var(--fg) 12%, transparent); }

/* ---------- Interactive opening screen: entrance + cursor-tracked orb ---------- */
/* `:not(.note)` because the note is a direct child of the hero and this
   animation's filled end state (opacity: 1) sits in the animation origin of
   the cascade, which outranks the normal `.note { opacity: 0 }` declaration —
   so the 3S panel was dragged permanently visible over the page title.
   `backwards` rather than `both` for the same reason in reverse: the end
   state here IS the element's natural state, so there is nothing to hold
   afterwards, and holding it left a transform on every hero child — the very
   stacking context designrules.md forbids in a text column. */
/* A section page's title block: as tall as its words, and close to the
   first project under it. The opening-screen height belongs to project
   pages, whose first screen is the title; on a section page the first
   screen has to show work. */
.hero--index {
  min-height: 0;
  padding-block: clamp(1.5rem, 4vw, 3rem) clamp(0.5rem, 2vw, 1.25rem);
}
.hero--index + .section { padding-top: clamp(1rem, 2.5vw, 2rem); }
.hero--index {
  /* The page's own top padding already sits above it; the hero adds no more. */
  margin-top: calc(-1 * clamp(1rem, 4vw, 3.5rem));
  padding-top: clamp(0.5rem, 2vw, 1.5rem);
}
.hero--index .hero__intro { margin-top: clamp(0.5rem, 1.5vw, 1rem); }

.hero > *:not(.note) {
  animation: hero-in 0.8s var(--ease) backwards;
}
.hero > *:nth-child(1) { animation-delay: 0.05s; }
.hero > *:nth-child(2) { animation-delay: 0.15s; }
.hero > *:nth-child(3) { animation-delay: 0.25s; }
.hero > *:nth-child(4) { animation-delay: 0.35s; }
.hero > *:nth-child(5) { animation-delay: 0.45s; }
@keyframes hero-in {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: translateY(0); }
}

.hero--orb::before {
  content: "";
  position: absolute;
  inset: -20%;
  z-index: -1;
  background: radial-gradient(420px 420px at var(--hx, 50%) var(--hy, 35%), rgba(255, 255, 255, 0.18), transparent 70%);
  filter: blur(10px);
  animation: orb-pulse 6s ease-in-out infinite;
}
[data-theme="light"] .hero--orb::before {
  background: radial-gradient(420px 420px at var(--hx, 50%) var(--hy, 35%), rgba(0, 0, 0, 0.08), transparent 70%);
}
@keyframes orb-pulse {
  0%, 100% { opacity: 0.7; }
  50% { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .hero > * { animation: none; }
  .hero--orb::before { animation: none; }
}

.section {
  padding-block: clamp(2rem, 5vw, 3.5rem);
}
.section__heading { font-size: var(--fs-2); margin: 0 0 0.25rem; letter-spacing: 0.02em; }
.section__subheading { font-size: var(--fs-3); color: var(--fg-dim); margin: 0 0 1rem; }
.section__eyebrow { font-size: var(--fs-small); letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-dim); margin-bottom: 0.75rem; }

/* ---------- Skills (Code page) ----------
   One row per language: the name and five dots on a line, one line of
   description under it. Dots are filled white for the rating and black
   with an outline for the rest — the one place circles are used as marks,
   asked for by name. The work made in that language shows on hover or
   keyboard focus, in a panel under the row; on a touchscreen it sits in
   the row, because nothing there hovers. */
.skills {
  list-style: none;
  margin: clamp(1.5rem, 4vw, 2.5rem) auto 0;
  padding: 0;
  max-width: 62rem;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0 clamp(2rem, 5vw, 4rem);
}
@media (min-width: 900px) {
  .skills { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.skill {
  position: relative;
  padding: 1rem 0;
  border-top: 1px solid var(--rule, rgba(241, 241, 241, 0.13));
  outline: none;
}
.skill__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.skill__name { font-size: var(--fs-4); letter-spacing: 0.04em; }
.skill__dots { display: inline-flex; gap: 6px; flex: 0 0 auto; }
.skill__dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--bg);
  box-shadow: inset 0 0 0 1px var(--fg-dim);
}
.skill__dot.is-on { background: var(--fg); box-shadow: none; }
.skill__note {
  margin: 0.35rem 0 0;
  font-size: var(--fs-small);
  line-height: 1.55;
  color: var(--fg-dim);
  text-align: left;
}
.skill--has-works { cursor: default; }
.skill--has-works .skill__name::after {
  content: " +";
  color: var(--fg-dim);
}
.skill:focus-visible .skill__name { text-decoration: underline; text-underline-offset: 0.3em; }

.skill__works {
  position: absolute;
  z-index: 20;
  top: calc(100% - 0.25rem);
  left: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  width: max-content;
  /* Not 92vw: vw counts the scrollbar the viewport does not have left to
     give, and this panel starts at the page gutter rather than at the
     edge. Both come off the ceiling, so the only widths it changes are
     the narrow ones where it was running off the page. */
  max-width: min(36rem, calc(100vw - 4.5rem));
  padding: 0.9rem;
  background: var(--bg);
  box-shadow: 0 0 0 1px var(--rule, rgba(241, 241, 241, 0.13));
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  transition: opacity 0.35s var(--ease), transform 0.35s var(--ease), visibility 0s linear 0.35s;
}
@media (min-width: 900px) {
  .skill:nth-child(even) .skill__works { left: auto; right: 0; }
}
.skill:hover .skill__works,
.skill:focus-within .skill__works {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition: opacity 0.35s var(--ease), transform 0.35s var(--ease), visibility 0s;
}
.skill__work {
  display: grid;
  gap: 0.35rem;
  width: 8.5rem;
  font-size: var(--fs-small);
  line-height: 1.35;
  opacity: 0.84;
  transition: opacity 0.3s var(--ease);
}
.skill__work:hover, .skill__work:focus-visible { opacity: 1; }
.skill__work img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: contain;
  background: #000;
  display: block;
}
/* A work with no still gets a plate the size of a thumbnail with its name
   set on it, so the row of previews stays one height. */
.skill__work > * { min-width: 0; }
.skill__plate {
  display: grid;
  place-items: center;
  width: 100%;
  overflow: hidden;
  overflow-wrap: anywhere;
  aspect-ratio: 16 / 9;
  padding: 0.5rem;
  background: #111;
  text-align: center;
  font-size: 0.7rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1.4;
}

/* No hover to reveal with: the work stays in the row, in the flow. Same
   test as the home labels — a phone can report a coarse pointer without
   reporting hover: none. */
@media (hover: none), (pointer: coarse) {
  .skill__works {
    position: static;
    opacity: 1;
    visibility: visible;
    transform: none;
    width: auto;
    max-width: none;
    padding: 0.75rem 0 0;
    background: none;
    box-shadow: none;
  }
  .skill__work { width: 7rem; }
  .skill__plate { font-size: 0.6rem; letter-spacing: 0.08em; }
  .skill--has-works .skill__name::after { content: ""; }
}
@media (prefers-reduced-motion: reduce) {
  .skill__works, .skill__work { transition: none; }
}

/* ---------- Hiring block (contact page) ----------
   Four facts under the CV box, on the page's centre line: the key in the
   eyebrow's voice, the answer in body type beside it on a wide screen and
   under it on a phone. No box, no rule — it is information, not a card. */
.hiring {
  display: grid;
  gap: 0.6rem;
  max-width: 34rem;
  margin: clamp(1.5rem, 4vw, 2.25rem) auto 0;
  text-align: center;
}
.hiring__row { display: grid; gap: 0.15rem; }
.hiring dt {
  font-size: var(--fs-small);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
.hiring dd { margin: 0; font-size: var(--fs-body); line-height: 1.5; }
/* The asterisk and its note sit under the roles without competing with
   them: smaller than the rows, and a step dimmer. "Ready to relocate" is
   the useful half and it is said plainly; which places are out is a
   conversation, not a line on a page. */
.hiring__mark {
  font-size: 0.75em;
  vertical-align: super;
  line-height: 0;
  margin-left: 0.15em;
  opacity: 0.7;
}
.hiring__note {
  margin: 0.75rem 0 0;
  font-size: var(--fs-small);
  line-height: 1.5;
  color: var(--fg-dim);
  opacity: 0.8;
}

/* ---------- Quotes ----------
   Someone else's words about the work. Set larger than body and in the
   thin italic, centred on the page's axis, with the speaker's role beneath
   in the eyebrow's voice. No box, no rule, no oversized quotation mark: the
   italic and the attribution are enough to say whose voice this is. */
.section--quotes { display: grid; gap: var(--space-figure); }
.quote { margin: 0 auto; max-width: 38ch; text-align: center; }
.quote blockquote { margin: 0; }
.quote blockquote p {
  margin: 0;
  font-size: var(--fs-3);
  font-style: italic;
  line-height: 1.5;
  text-align: center;
  hyphens: manual;
}
.quote figcaption {
  margin-top: 1rem;
  font-size: var(--fs-small);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

/* ---------- Essay pages (`layout: "essay"`) ----------
   A written piece with figures set into it is one continuous column, not a
   stack of separate panels. By default each section pads itself top and
   bottom, so between two sections the two paddings add up: an image ends up
   with a small gap to the paragraph it follows and more than twice that to
   the one it precedes — it reads as belonging to neither. Here the padding
   is traded for margins, which collapse against each other, so every
   interval on the page is the same one and a figure sits evenly between the
   text above and below it. */
.page--essay .section { padding-block: 0; }
.page--essay .section + .section { margin-top: var(--space-figure); }
.page--essay .gallery-wrap,
.page--essay .stats { margin-block: var(--space-figure); }

/* A supporting image in an essay is a figure, not a footnote: at the default
   disc size it reads as a dot adrift in a wide column. It stays a disc — the
   shape still means "a single image" — but at a size that holds the column. */
.page--essay .gallery figure,
.page--essay .gallery__item { width: clamp(200px, 30vw, 340px); }

/* The standfirst is a subtitle, not a second headline: holding it to a
   narrower measure keeps it from running the full width and competing with
   the title it belongs to. */
.page--essay .hero__sub { max-width: 46ch; margin-inline: auto; }

/* ---------- Section backdrop ----------
   A picture set behind a section instead of in its reading order. It rests
   just at the edge of visible so the words in front stay the thing you
   read, and comes up to half opacity while the block is hovered. The
   portrait already vignettes to black, so it meets the page without an
   edge and needs no mask. */
.section--backdrop { position: relative; }
/* Everything except the backdrop itself is lifted above it. */
.section--backdrop > *:not(.section__backdrop) { position: relative; z-index: 1; }
/* A registered property so the torch's radius can actually animate — an
   unregistered custom property jumps between values instead of easing. */
@property --beam {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

.section__backdrop {
  position: absolute;
  z-index: 0;
  /* Half the size it was. The box the picture is fitted into is halved
     (it was 108% x 110% of the section), so the picture halves with it at
     any viewport — rather than a fixed size tuned to one screen width. */
  top: 50%;
  left: 50%;
  width: 54%;
  height: 55%;
  /* -50% centres it; the extra -65% carries it well off the form's axis. */
  transform: translate(-115%, -50%);
  pointer-events: none;
}
.section__backdrop span {
  position: absolute;
  inset: 0;
  background-image: var(--img);
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}
/* At rest: the whole picture, dim enough to read as a shadow on the page. */
.section__backdrop-base {
  opacity: 0.07;
  filter: brightness(0.6);
}
/* The torch. --mx/--my follow the pointer (wireBackdrop in js/app.js) and
   --beam is its radius, 0 until the block is hovered, so nothing is ever
   revealed all at once. Half opacity with the brightness held back: lit,
   never solid. */
.section__backdrop-beam {
  --mx: 50%;
  --my: 50%;
  opacity: 0.5;
  filter: brightness(0.9);
  -webkit-mask-image: radial-gradient(
    circle var(--beam) at var(--mx) var(--my),
    #000 0%,
    rgba(0, 0, 0, 0.55) 55%,
    transparent 100%
  );
  mask-image: radial-gradient(
    circle var(--beam) at var(--mx) var(--my),
    #000 0%,
    rgba(0, 0, 0, 0.55) 55%,
    transparent 100%
  );
  transition: --beam 0.5s var(--ease);
}
.section--backdrop:hover .section__backdrop-beam { --beam: 180px; }
/* No pointer to carry a torch: tabbing into the form lights the whole thing. */
.section--backdrop:focus-within .section__backdrop-beam { --beam: 900px; }
@media (prefers-reduced-motion: reduce) {
  .section__backdrop-beam { transition: none; }
}
@media (prefers-reduced-motion: reduce) {
  .section__backdrop { transition: none; }
}

/* ---------- Typography & justification (design rules) ---------- */
.prose { position: relative; max-width: var(--measure); }
/* Body copy rests at full ink on every ground: it inherits the page's own
   colour and sets no opacity. It used to rest at 0.82 and lift to 1 under
   the cursor — a nice gesture that cost the one thing a paragraph is for.
   Reading should not require hovering, so the lift is gone and the text
   sits at full strength: 18.6:1 on black. The editorial pages keep their
   own warmer ink, which this no longer overrides. */
.prose p {
  text-align: justify;
  text-justify: inter-word;
  hyphens: auto;
  -webkit-hyphens: auto;
  word-spacing: var(--justify-tracking);
  text-wrap: pretty;
  margin-block: var(--space-para);
}
.prose p:first-child { margin-block-start: 0; }

/* ---------- Triads (UX page) ---------- */
.triad { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-block: 1.5rem; }
.triad__item { padding: 1.25rem; }
.triad__item h3 { margin: 0 0 0.5rem; font-size: var(--fs-4); letter-spacing: 0.1em; }
.triad__item p { margin: 0; font-size: var(--fs-small); color: var(--fg-dim); }

/* ---------- Gallery: a scrollable row of filled discs ----------
   Each image fills its disc (`cover`) and the rim is feathered away with a
   radial mask — same treatment as the home moon-cycle, so the whole site
   speaks one visual language. Scrolls horizontally with snap points. */
.gallery-wrap {
  position: relative;
  margin-block: 1.5rem;
  padding-bottom: 2.75rem;
}
.gallery-wrap--static { padding-bottom: 0; }
.gallery-wrap--static .gallery-nav,
.gallery-wrap--static .gallery-counter { display: none; }

.gallery-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  margin-top: -1.375rem;
  z-index: 2;
  width: 40px;
  height: 40px;
  background: none;
  border: none;
  color: var(--fg);
  font-size: 1.1rem;
  cursor: pointer;
  opacity: 0.56;
  transition: opacity 0.3s var(--ease);
}
.gallery-nav:hover { opacity: 1; }
.gallery-nav[disabled] { opacity: 0.12; }
/* On the two pale grounds the resting 0.56 put the arrow at 3.9:1 against
   Gem-bot's grey. Dark pages pass comfortably; these rest a step stronger,
   the same step the nav links take on the same grounds. */
[data-theme="gembot"] .gallery-nav:not([disabled]),
[data-theme="sersery"] .gallery-nav:not([disabled]) { opacity: 0.72; }
.gallery-nav--prev { left: clamp(-0.75rem, -1vw, -0.25rem); }
.gallery-nav--next { right: clamp(-0.75rem, -1vw, -0.25rem); }

.gallery-counter {
  position: absolute;
  bottom: 0.5rem;
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--fs-small);
  letter-spacing: 0.15em;
  color: var(--fg-dim);
}

.gallery {
  display: flex;
  gap: clamp(1rem, 3vw, 2.25rem);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  /* The rail fades out at both ends instead of ending on a hard cut. */
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%);
}
.gallery::-webkit-scrollbar { display: none; }
.gallery figure, .gallery__item {
  margin: 0;
  display: block;
  flex: 0 0 auto;
  width: clamp(150px, 21vw, 250px);
  scroll-snap-align: center;
  /* Breathing room inside the item's own box, so a hover glow lives here
     rather than spilling onto the work beside it. */
  padding: var(--glow-room);
}

/* ---------- Project cards ----------
   A piece with analysis and a workflow behind it gets a 16:9 card rather
   than a disc: wider, quieter, and it opens its own page. Hovering cycles
   through variations of the work. */
.project-card {
  margin: 0;
  display: block;
  flex: 0 0 auto;
  width: clamp(260px, 38vw, 460px);
  scroll-snap-align: center;
  padding: var(--glow-room);
}
.project-card__frame {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: color-mix(in srgb, var(--fg) 5%, transparent);
  box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
  transition: box-shadow 0.5s var(--ease);
}
.project-card:hover .project-card__frame {
  box-shadow: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--fg) 30%, transparent);
}
.project-card__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* Thumbnails are filled 16:9 boxes, so they crop. `--focus` picks what
     survives the crop for images that are not already landscape. */
  /* Filled 16:9 by default. An image whose own ground is the page colour can
     opt into `contain` instead: it still reads as a filled box, but nothing
     is cropped — the Ready to Ungreed cover is 1.43:1 and a cover crop ate
     both its wordmark and the bottom of the earth. */
  object-fit: var(--fit, cover);
  object-position: var(--focus, center);
  opacity: 0;
  transition: opacity 0.7s var(--ease);
}
.project-card__layer.active { opacity: 1; }

/* ---------- Editorial layout ----------
   Proportions, type and colour measured off the live page: Montserrat 400,
   warm taupe on black, left-aligned (not justified), generous vertical air. */
/* Near-black, because the weight came back to 100 with the rest of the site
   and a thin face on a cream ground needs every bit of contrast the stroke no
   longer provides. The same trade the light CV sheet makes: fix readability
   with the ink, never with the weight. At 58/54/52 this measured 10.7:1 and
   still read faint — thin type is a stroke problem, not a ratio problem, so
   the ink goes as dark as the page's warmth allows. */
.page--editorial { --edit-ink: rgb(26, 21, 19); }
.page--editorial .section { padding-block: clamp(2.5rem, 7vw, 6rem); }

/* ---------- Editorial full bleed ----------
   The shell's side padding is dropped for the whole page (see the
   `page-editorial` body class in js/app.js) so images reach the viewport
   edges, and only the text blocks put it back. Done this way round rather
   than with `100vw` + negative margins, which would count the scrollbar in
   and reintroduce a horizontal overflow. */
body.page-editorial #app-content { padding-inline: 0; max-width: none; }
.page--editorial > .section > .prose,
.page--editorial .split,
.page--editorial .section > h2,
.page--editorial .section > h3 {
  max-width: var(--content-max);
  margin-inline: auto;
  box-sizing: border-box;
  padding-inline: clamp(1.25rem, 5vw, 3rem);
}
/* These two already hold their own measure — they only need the gutter back
   so they never touch the edge on a narrow viewport. */
.page--editorial .lead { box-sizing: content-box; padding-inline: clamp(1.25rem, 5vw, 3rem); }
.page--editorial .icon-row { padding-right: clamp(1.25rem, 5vw, 3rem); }

/* Media runs the full width of the page. */
.page--editorial .band,
.page--editorial .embed,
.page--editorial .trio { width: 100%; }
.page--editorial .figure { width: 100%; }
.page--editorial p {
  color: var(--edit-ink);
  /* One font, one weight — this page was the last 400 left in the body copy.
     The ink above carries the readability instead. */
  font-weight: var(--fw);
  text-align: left;
  hyphens: none;
  text-wrap: pretty;
}

.icon-row {
  display: flex;
  align-items: center;
  gap: clamp(1.25rem, 6vw, 5.5rem);
  flex-wrap: wrap;
  padding-left: clamp(0rem, 8vw, 100px);
}
.icon-row__marks { display: flex; align-items: center; gap: 10px; }
.icon-row__marks img {
  height: 30px;
  width: auto;
  display: block;
  /* The Pinterest, Redbubble and Teepublic marks are PNGs with white boxes
     baked into them, and they sat on a cream page as three white tiles. Over
     a light ground, multiply drops white to the ground colour and leaves the
     ink alone — so the marks read as printed on the page rather than pasted
     onto it, without retouching three files that belong to someone else. */
  mix-blend-mode: multiply;
}
.icon-row__title {
  margin: 0;
  font-size: clamp(1rem, 2.1vw, 25px);
  line-height: 1.2;
  letter-spacing: 0.05em;
}

.lead {
  max-width: 700px;
  margin-inline: auto;
  display: grid;
  gap: clamp(1.25rem, 3vw, 2rem);
}
.lead p {
  margin: 0;
  font-size: clamp(1rem, 2.1vw, 25px);
  line-height: 1.2;
  letter-spacing: 0.05em;
}

.figure {
  display: block;
  width: var(--fig-w, 70%);
  margin-inline: auto;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
}
.figure img {
  width: 100%;
  height: auto;
  display: block;
  transition: opacity 0.4s var(--ease);
}
.figure:hover img { opacity: 0.88; }

.split {
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  align-items: start;
  gap: clamp(1.5rem, 5vw, 4rem);
}
.split__text { display: grid; gap: clamp(1.25rem, 3vw, 2.25rem); }
.split__text p {
  margin: 0;
  font-size: clamp(0.9rem, 1.7vw, 20px);
  line-height: 1.2;
  letter-spacing: 0.05em;
}
.split__media {
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  justify-self: end;
  width: min(100%, 422px);
}
.split__media img { width: 100%; height: auto; display: block; transition: opacity 0.4s var(--ease); }
.split__media:hover img { opacity: 0.88; }

.trio {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* The three shots aren't the same shape, so they're hung on a shared
     centre line rather than a shared top edge — cropping them to match
     would cut the product. */
  align-items: center;
  justify-items: center;
  gap: clamp(0.5rem, 1.2vw, 14px);
}
.trio__item {
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  display: block;
  width: 100%;
}
.trio__item img { width: 100%; height: auto; display: block; transition: opacity 0.4s var(--ease); }
.trio__item:hover img { opacity: 0.88; }

@media (max-width: 760px) {
  .split { grid-template-columns: 1fr; }
  .split__media { justify-self: center; }
  .trio { grid-template-columns: 1fr; }
  .figure { width: 100% !important; }
}

/* ---------- Editorial bands & collages ----------
   For pages that read as a magazine spread: full-bleed image bands with the
   wordmark laid over them, and collage grids between the text columns. */
.band {
  position: relative;
  width: 100%;
  margin-block: clamp(1.5rem, 4vw, 3rem);
  overflow: hidden;
}
.band img {
  width: 100%;
  height: clamp(220px, 42vh, 520px);
  object-fit: cover;
  display: block;
}

/* A banner that escapes the shell's padding to the document's own edge,
   the same measurement the bleed decks use (--sbw is the measured
   scrollbar, so it stops short of it rather than forcing the page
   sideways). Its picture keeps its whole self: a title card has words in
   it, and `cover` would crop them. */
.band--bleed {
  width: calc(100vw - var(--sbw, 0px));
  margin-inline: calc(50% - 50vw + var(--sbw, 0px) / 2);
  margin-block: 0 clamp(1.5rem, 4vw, 3rem);
}
.band--bleed img {
  height: auto;
  object-fit: contain;
}
.band__mark {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(1.75rem, 6vw, 4rem);
  color: #fff;
  text-shadow: 0 2px 30px rgba(0, 0, 0, 0.45);
  pointer-events: none;
}

.collage {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
  gap: clamp(0.5rem, 1.5vw, 1rem);
  margin-block: clamp(1.5rem, 4vw, 2.5rem);
}
.collage__item {
  display: block;
  width: 100%;
  padding: 0;
  border: none;
  background: none;
  overflow: hidden;
  cursor: pointer;
}
.collage__item img {
  width: 100%;
  height: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  display: block;
  transition: opacity 0.4s var(--ease);
}
.collage__item:hover img { opacity: 0.85; }

/* ---------- Embedded project experiences ---------- */
.embed-frame {
  width: 100%;
  margin-block: clamp(1.5rem, 4vw, 2.5rem);
  background: #000;
}
.embed-frame iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}
.embed-frame--auto iframe { height: 100%; }
.gallery__item {
  background: none;
  border: none;
  /* Not `padding: 0` — the item keeps its --glow-room so a hover glow
     stays inside its own box. */
  font: inherit;
  color: inherit;
  text-align: inherit;
  cursor: pointer;
}
.gallery__frame {
  position: relative;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  overflow: hidden;
  background: color-mix(in srgb, var(--fg) 5%, transparent);
}
/* A plain light-up behind the disc on hover. The blur stays within
   --glow-room, so the halo never reaches a neighbour. */
.gallery__frame {
  box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
  transition: box-shadow 0.5s var(--ease);
}
.gallery a:hover .gallery__frame,
.gallery__item:hover .gallery__frame {
  box-shadow: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--fg) 30%, transparent);
}
.gallery__frame img, .gallery__frame video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* A disc crops to a circle, so an item can say what must survive it —
     a full-body character render loses its head at the default centre. */
  object-position: var(--focus, center);
  display: block;
  transition: opacity 0.4s var(--ease);
}
.gallery a:hover .gallery__frame img,
.gallery a:hover .gallery__frame video,
.gallery__item:hover .gallery__frame img,
.gallery__item:hover .gallery__frame video { opacity: 0.82; }
.gallery figcaption {
  font-size: var(--fs-small);
  color: var(--fg-dim);
  margin-top: 0.75rem;
  text-align: center;
}

/* ---------- Carousel ----------
   One slide at a time, filling the frame, paged by the rail's own arrows and
   counter rather than by a scrollbar. `contain` because a sheet is a
   document — nothing may be cropped off it — and the page shares its ground,
   so there is no letterbox to see. */
.gallery--carousel {
  scroll-snap-type: x mandatory;
  -webkit-mask-image: none;
  mask-image: none;
}
.gallery--carousel .gallery__item {
  width: 100%;
  flex: 0 0 100%;
  padding: 0;
  scroll-snap-align: center;
}
.gallery--carousel .gallery__frame {
  aspect-ratio: 16 / 9;
  border-radius: 0;
  background: none;
  box-shadow: none;
}
.gallery--carousel .gallery__item:hover .gallery__frame { box-shadow: none; }
.gallery--carousel .gallery__frame img { object-fit: contain; }

/* ---------- Slide decks ----------
   A project's own slide sequence is reference material, not decoration. The
   slides stack vertically at full width and read straight down the page —
   the way you'd page through the PDF — each one `contain`ed so no line of
   text is ever clipped. Never discs, never a sideways rail. */
.gallery--deck {
  display: block;
  overflow: visible;
  scroll-snap-type: none;
  -webkit-mask-image: none;
  mask-image: none;
}
.gallery--deck .gallery__item {
  width: 100%;
  margin-bottom: clamp(0.75rem, 2vw, 1.5rem);
  scroll-snap-align: none;
  padding: var(--glow-room);
}
.gallery--deck .gallery__frame {
  aspect-ratio: 16 / 9;
  border-radius: 0;
  background: color-mix(in srgb, var(--fg) 4%, transparent);
  box-shadow: none;
}
/* No glow on a slide, hovered or not. The hover light means "this opens
   something" and belongs to a card you can go to; a deck is reference
   material — you page through it the way you page through the PDF, and there
   is nothing behind any one sheet. The rule said exactly this already, but
   only for `--natural` decks, which made the distinction the shape of the
   frame rather than what the thing is: a 16:9 slide is as much a plate in a
   document as a portrait one. Now it is said once, for every deck. */
.gallery--deck .gallery__frame img,
.gallery--deck .gallery__frame video {
  object-fit: contain;
}

/* Laid across rather than down — a timeline. Each step takes an equal share
   of the row, so the progression reads left to right in one look instead of
   asking the reader to hold three screens in their head. Stacks again when
   there is not enough width to tell them apart. */
.gallery--deck.gallery--natural.gallery--row {
  display: flex;
  align-items: flex-start;
  gap: clamp(0.75rem, 2vw, 1.5rem);
  /* Holding the row narrower than the column is what sizes the pictures in
     it: the cells divide whatever the row is given, and each picture fills
     its cell. One number rather than a cap on every item. */
  max-width: var(--row-max, none);
  margin-inline: auto;
}
.gallery--deck.gallery--natural.gallery--row .gallery__item {
  flex: 1 1 0;
  min-width: 0;
  margin-bottom: 0;
  padding: 0;
}
.gallery--deck.gallery--natural.gallery--row .gallery__frame img {
  width: auto;
  max-width: var(--item-max, 100%);
  max-height: none;
}
@media (max-width: 720px) {
  .gallery--deck.gallery--natural.gallery--row { flex-wrap: wrap; }
  .gallery--deck.gallery--natural.gallery--row .gallery__item { flex: 1 1 100%; }
}

/* ---------- A picture inside the text ----------
   Floated so the paragraphs close around it. This is for a small plate in
   a long passage: a half-column picture in its own band leaves a lot of
   empty page beside it, and the text is what the picture is about. */
.prose-aside {
  float: left;
  width: var(--aside-w, 240px);
  max-width: 45%;
  margin: 0.35rem clamp(1.25rem, 3vw, 2.25rem) 1rem 0;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
}
.prose-aside--right {
  float: right;
  margin: 0.35rem 0 1rem clamp(1.25rem, 3vw, 2.25rem);
}
.prose-aside img {
  width: 100%;
  height: auto;
  display: block;
}
.prose-aside figcaption {
  margin-top: 0.6rem;
  font-size: var(--fs-small);
  letter-spacing: 0.06em;
  color: var(--fg-dim);
  text-align: left;
}
/* The paragraphs must not hand a float on to whatever follows them. */
.prose::after { content: ""; display: block; clear: both; }

@media (max-width: 640px) {
  /* Too narrow to wrap around: the picture takes its own line instead of
     squeezing the text into a two-word column beside it. */
  .prose-aside,
  .prose-aside--right {
    float: none;
    width: 100%;
    max-width: 100%;
    margin: 0 0 1.5rem;
  }
}

/* A deck of documentation, not of slides. The pieces here are every shape —
   a portrait ink drawing, a very wide symbol row, a laptop mockup — so a
   fixed 16:9 frame would letterbox some and crop none of them well. The
   frame takes each picture’s own dimensions instead, centred, capped to the
   column and to most of the viewport so nothing runs away with the page. */
.gallery--deck.gallery--natural .gallery__frame {
  aspect-ratio: auto;
  background: none;
  display: flex;
  justify-content: center;
}

/* A plate set against an edge rather than on the centre line. Its caption
   goes with it — a centred caption under a left-hung picture reads as a
   mistake rather than as a choice. */
.gallery--natural .gallery__item[data-align="left"] .gallery__frame { justify-content: flex-start; }
.gallery--natural .gallery__item[data-align="left"] figcaption { text-align: left; }
.gallery--natural .gallery__item[data-align="right"] .gallery__frame { justify-content: flex-end; }
.gallery--natural .gallery__item[data-align="right"] figcaption { text-align: right; }
.gallery--natural .gallery__item[data-align] .gallery__frame img { margin-inline: 0; }
/* `width`/`height` attributes are on these images so the box is reserved
   before they load; auto here lets CSS size them while the attributes keep
   supplying the ratio. */
.gallery--deck.gallery--natural .gallery__frame img,
.gallery--deck.gallery--natural .gallery__frame video {
  width: auto;
  height: auto;
  /* --item-max lets a single plate be held smaller than the column when
     its artwork wants the room more than the page does. */
  max-width: var(--item-max, 100%);
  max-height: 78vh;
  margin-inline: auto;
}

/* ---------- Full-bleed deck ----------
   Some decks are the work itself rather than reference beside it, and the
   source page ran those edge to edge. Each slide keeps its own aspect
   ratio — nothing is letterboxed into a 16:9 box and nothing is cropped
   out of one — and the strip escapes the shell's padding and max-width to
   the document's own edge. `--sbw` is the measured scrollbar, so the bleed
   stops short of it instead of forcing the page sideways. */
.gallery--bleed .gallery__item {
  display: block;
  width: calc(100vw - var(--sbw, 0px));
  margin-inline: calc(50% - 50vw + var(--sbw, 0px) / 2);
  margin-bottom: 0;
  padding: 0;
}
/* A bleeding slide keeps its own aspect ratio, so its height is only known
   once it has loaded — until then it is zero pixels tall, and a deck of
   twenty takes up no room at all. Reserving a height keeps the page the
   length it will actually be: a slide arriving grows from the placeholder
   rather than from nothing, so the deck doesn't lurch as you read down it. */
.gallery--bleed .gallery__frame {
  aspect-ratio: auto;
  background: none;
  box-shadow: none;
  min-height: 42vh;
}
.gallery--bleed .gallery__frame.is-loaded { min-height: 0; }
/* On the picture too, not only the frame around it: the frame alone leaves
   the image itself at zero height inside a tall box, which is the shift
   this is meant to avoid. Released the moment the image is in. */
.gallery--bleed .gallery__frame:not(.is-loaded) img { height: 42vh; }
.gallery--bleed .gallery__item:hover .gallery__frame { box-shadow: none; }
.gallery--bleed .gallery__frame img,
.gallery--bleed .gallery__frame video {
  width: 100%;
  height: auto;
  object-fit: cover;
}

.play-icon {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
  color: #fff;
  background: rgba(0, 0, 0, 0.25);
  pointer-events: none;
}

/* ---------- Lightbox ---------- */
/* ---------- Slideshow ----------
   A long deck read the other way round: played instead of scrolled. It
   borrows the lightbox's manners — full-bleed black, thin arrows either
   side, a counter — and adds the two things a player needs and a viewer
   doesn't get from scrolling: a pause, and control of the pace. */
.show {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.94);
  opacity: 0;
  transition: opacity 0.3s var(--ease);
}
.show.open { opacity: 1; }
.show[hidden] { display: none; }

.show__stage {
  max-width: min(94vw, 1500px);
  max-height: 78vh;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* A slide is a document: it is shown whole, never cropped to the frame. */
.show__stage img {
  max-width: 100%;
  max-height: 78vh;
  object-fit: contain;
  opacity: 0;
  transition: opacity 0.45s var(--ease);
}
.show__stage img.is-in { opacity: 1; }

.show__close, .show__prev, .show__next {
  position: absolute;
  background: none;
  border: none;
  color: var(--fg-dim);
  font-size: 1.5rem;
  line-height: 1;
  padding: 12px;
  opacity: 0.68;
  transition: opacity 0.3s var(--ease), color 0.3s var(--ease);
}
.show__close:hover, .show__prev:hover, .show__next:hover,
.show__close:focus-visible, .show__prev:focus-visible, .show__next:focus-visible {
  color: #fff;
  opacity: 1;
  outline: none;
}
.show__close { top: clamp(1rem, 3vw, 2rem); right: clamp(1rem, 3vw, 2rem); font-size: 1.9rem; }
.show__prev { left: clamp(0.5rem, 3vw, 2rem); top: 50%; transform: translateY(-50%); }
.show__next { right: clamp(0.5rem, 3vw, 2rem); top: 50%; transform: translateY(-50%); }

/* ---------- the panel ---------- */
.show__panel {
  position: absolute;
  left: 50%;
  bottom: clamp(1.1rem, 4vh, 2.5rem);
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: clamp(1rem, 4vw, 2.75rem);
  flex-wrap: wrap;
  justify-content: center;
  max-width: min(94vw, 760px);
}
.show__panel button,
.show__panel label,
.show__panel output,
.show__counter {
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg);
}
.show__play {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  background: none;
  border: none;
  opacity: 0.76;
  transition: opacity 0.3s var(--ease);
}
.show__play:hover, .show__play:focus-visible { opacity: 1; outline: none; }
/* The mark is the state it will go to, drawn rather than set in type so it
   reads at a glance while a slide is up. */
.show__icon {
  width: 9px;
  height: 11px;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.show__icon.is-playing {
  clip-path: polygon(0 0, 32% 0, 32% 100%, 0 100%, 0 0, 68% 0, 100% 0, 100% 100%, 68% 100%, 68% 0);
}

.show__speed { display: inline-flex; align-items: center; gap: 0.75rem; }
.show__speedLabel { opacity: 0.56; }
.show__secs { opacity: 0.76; font-variant-numeric: tabular-nums; min-width: 3.4em; }
/* A line with a mark on it, in the site's own weight — the platform slider
   is chrome-blue and rounded, which belongs to no part of this design. */
.show__range {
  -webkit-appearance: none;
  appearance: none;
  width: clamp(90px, 22vw, 190px);
  height: 1px;
  background: rgba(241, 241, 241, 0.3);
  outline: none;
}
.show__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--fg);
  cursor: pointer;
}
.show__range::-moz-range-thumb {
  width: 11px;
  height: 11px;
  border: none;
  border-radius: 50%;
  background: var(--fg);
  cursor: pointer;
}
.show__range:focus-visible { box-shadow: 0 0 0 1px rgba(241, 241, 241, 0.5); }
.show__counter { opacity: 0.64; font-variant-numeric: tabular-nums; }

/* How much of this slide is left, along the very bottom of the screen. */
.show__progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: rgba(241, 241, 241, 0.12);
}
.show__progress span {
  display: block;
  height: 100%;
  width: 0;
  background: rgba(241, 241, 241, 0.55);
}

/* ---------- the way in ---------- */
.show-open {
  display: inline-flex;
  align-items: center;
  gap: 0.65rem;
  margin: 0 auto clamp(1rem, 3vw, 1.75rem);
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg);
  opacity: 0.68;
  background: none;
  border: none;
  /* A finger-sized target (the line of text alone is 22px tall), without
     moving the label: the padding is paid back from the margin below. */
  min-height: 44px;
  padding: 0 0.5rem;
  margin-top: -11px;
  margin-bottom: calc(clamp(1rem, 3vw, 1.75rem) - 11px);
  transition: opacity 0.3s var(--ease);
}
.show-open:hover, .show-open:focus-visible { opacity: 1; outline: none; }
.show-open::before {
  content: "";
  width: 8px;
  height: 10px;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.show-openWrap { display: flex; justify-content: center; }

@media (prefers-reduced-motion: reduce) {
  .show, .show__stage img, .show__play, .show-open { transition: none; }
}

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.9);
  opacity: 0;
  transition: opacity 0.3s var(--ease);
}
.lightbox.open { opacity: 1; }
.lightbox[hidden] { display: none; }
.lightbox__stage {
  max-width: min(92vw, 1100px);
  max-height: 82vh;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.2s var(--ease);
}
.lightbox__stage img, .lightbox__stage video {
  max-width: 100%;
  max-height: 82vh;
}
.lightbox__close, .lightbox__prev, .lightbox__next {
  position: absolute;
  background: none;
  border: none;
  color: rgba(255, 255, 255, 0.7);
  width: 44px;
  height: 44px;
  font-size: 1.1rem;
  cursor: pointer;
  transition: color 0.25s var(--ease);
}
.lightbox__close:hover, .lightbox__prev:hover, .lightbox__next:hover { color: #fff; }
.lightbox__close { top: clamp(1rem, 3vw, 2rem); right: clamp(1rem, 3vw, 2rem); }
.lightbox__prev { left: clamp(0.75rem, 3vw, 2rem); top: 50%; transform: translateY(-50%); }
.lightbox__next { right: clamp(0.75rem, 3vw, 2rem); top: 50%; transform: translateY(-50%); }
.lightbox__caption {
  position: absolute;
  bottom: clamp(3.25rem, 7vw, 4.5rem);
  left: 50%;
  transform: translateX(-50%);
  width: min(90vw, 62ch);
  margin: 0;
  text-align: center;
  color: rgba(255, 255, 255, 0.8);
  font-size: var(--fs-small);
  line-height: 1.7;
}
.lightbox__caption[hidden] { display: none; }
.lightbox__counter {
  position: absolute;
  bottom: clamp(1rem, 3vw, 2rem);
  left: 50%;
  transform: translateX(-50%);
  color: rgba(255, 255, 255, 0.7);
  font-size: var(--fs-small);
  letter-spacing: 0.15em;
}
@media (max-width: 600px) {
  .lightbox__prev, .lightbox__next { width: 36px; height: 36px; }
}

/* ---------- Contact form ---------- */
/* A sparse page holds its content in a column instead of stranding it in a
   wide empty field. */
.page--contained {
  max-width: 640px;
  margin-inline: auto;
}
.page--contained .section { padding-block: clamp(1.5rem, 4vw, 2.5rem); }

/* ---------- Section opener ----------
   A title block that is itself the way into a project page. The arrow is the
   affordance — the same thin mark the strip and the lightbox use — so the
   block needs no box or rule around it. */
.section-open {
  display: grid;
  gap: 0.35rem;
  justify-items: start;
  transition: opacity 0.3s var(--ease);
}
.section-open:hover { opacity: 0.72; }
.section-open__arrow {
  display: block;
  margin-top: 1.25rem;
  color: var(--fg);
  opacity: 0.52;
  transition: opacity 0.3s var(--ease), transform 0.45s var(--ease);
}
.section-open:hover .section-open__arrow { opacity: 1; transform: translateX(8px); }
.section-open__arrow svg {
  display: block;
  width: 31px;
  height: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ---------- Centred pages ----------
   A page that is one short block (contact) sits on the page's centre line
   rather than hard left, so the column reads as placed instead of stranded. */
.page--centered .section { text-align: center; }
.page--centered .prose p { text-align: center; }
.page--centered .contact-form label { text-align: center; }

.contact-form { display: grid; gap: 1rem; max-width: 480px; margin: 2rem auto 0; }
.contact-form label { font-size: var(--fs-small); letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-dim); display: block; margin-bottom: 0.4rem; }
.contact-form input, .contact-form textarea {
  width: 100%;
  background: transparent;
  border: 1px solid var(--border);
  padding: 0.75rem 0.9rem;
  color: var(--fg);
  font: inherit;
}
.contact-form button {
  /* No filled box behind it — the submit reads as type on the page like
     every other link on the site, marked by tracking and hover alone. */
  justify-self: center;
  background: none;
  color: var(--fg);
  border: none;
  padding: 0.75rem 1.75rem;
  font: inherit;
  letter-spacing: 0.1em;
  cursor: pointer;
  transition: opacity 0.25s var(--ease);
}
.contact-form button:hover { opacity: 0.85; }
/* Held while the message is in flight, so it can't be sent twice. Marked by
   opacity alone, like every other state on the site. */
.contact-form button[disabled] { opacity: 0.35; }
.contact-form button[disabled]:hover { opacity: 0.35; }
.form-note { font-size: var(--fs-small); color: var(--fg-dim); margin-top: 0.75rem; }
/* Where the form's data goes, under the button that sends it. Small and
   dim like the note above it, but held to a measure so it reads as one
   sentence rather than a strip of fine print. */
.form-privacy {
  max-width: 44ch;
  /* The empty status line above still takes a grid gap; pulled up by one
     gap so the note sits under the button, not a screen away from it. */
  margin: -1rem auto 0;
  font-size: var(--fs-small);
  line-height: 1.6;
  color: var(--fg-dim);
  text-align: center;
}

/* ---------- Footer ---------- */
.site-footer {
  padding: clamp(2rem, 5vw, 3.5rem) clamp(1.25rem, 5vw, 3rem) 2rem;
}
.site-footer__top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: flex-start;
  gap: 1.5rem 2rem;
  max-width: var(--content-max);
  margin-inline: auto;
  padding-bottom: 2rem;
}
.footer-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.75rem;
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--fs-small);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.footer-links a {
  display: inline-block;
  /* The footer is a full sitemap, so these are navigation rather than links
     inside a sentence — the 24x24 floor of WCAG 2.5.8 applies. They were 20px
     tall. Padding only; the row gap absorbs it and the type does not move.
     Raised again to the 44px the contact links and the CV box already use:
     at 31px tall with a 4px row gap, two rows of a wrapped sitemap sat close
     enough on a phone that a thumb aimed at one could land on the other. */
  padding-block: 0.75rem;
  opacity: 0.68;
  transition: opacity 0.25s var(--ease);
}
.footer-links a:hover, .footer-links a[aria-current="page"] { opacity: 1; }
.site-footer__bottom {
  max-width: var(--content-max);
  margin-inline: auto;
  padding-top: 1.5rem;
  font-size: var(--fs-small);
  color: var(--fg-dim);
}

/* ---------- The book ----------
   The writing is bound rather than stacked: sheets sit on the right of the
   spine and rotate away to the left as they turn, so each sheet's back
   becomes the next left-hand page. This is the one motion on the site that
   isn't a dissolve — a page turn is the whole point of the object. */
.book-wrap {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  grid-template-areas: "prev book next" "counter counter counter";
  align-items: center;
  gap: clamp(0.5rem, 2vw, 1.5rem);
  margin-block: clamp(1.5rem, 5vw, 3rem);
}
.book {
  grid-area: book;
  position: relative;
  perspective: 2600px;
  /* Each leaf comes out near 2:3 — book proportions, not a squat panel, and
     tall enough that an article sits on its page without being scrolled. */
  aspect-ratio: 4 / 3;
  max-height: 80vh;
}
.book__sheets {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  /* Closed, the book sits centred; opening it slides the spine to the middle
     so both halves stay in frame. */
  transform: translateX(-25%);
  transition: transform 0.9s var(--ease);
}
.book--open .book__sheets { transform: translateX(0); }

/* Pages are measured here at their real size before being bound into sheets
   (see paginateBook), then this host is removed. */
.book__flow {
  position: absolute;
  top: 0;
  left: 50%;
  width: 50%;
  height: 100%;
  visibility: hidden;
  pointer-events: none;
}
.book__flow .book__page { position: absolute; inset: 0; }

.book__sheet {
  position: absolute;
  top: 0;
  left: 50%;
  width: 50%;
  height: 100%;
  transform-origin: left center;
  transform-style: preserve-3d;
  transition: transform 1.05s cubic-bezier(0.66, 0.02, 0.28, 1);
}
.book__sheet.flipped { transform: rotateY(-180deg); }

.book__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  overflow: hidden;
}
.book__face--back { transform: rotateY(180deg); }

.book__page {
  position: relative;
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: clamp(1.25rem, 3.2vw, 3rem);
  /* Seamless: the page is the same black as everything else, with no gutter
     shading and no panel edge — the turn alone says where the fold is. */
  background: var(--bg);
}

.book__folio {
  flex: 0 0 auto;
  margin-top: auto;
  padding-top: clamp(0.75rem, 2vw, 1.25rem);
  text-align: center;
  font-size: var(--fs-small);
  letter-spacing: 0.3em;
  color: var(--fg-dim);
}

.book__page--cover {
  align-items: center;
  justify-content: center;
}
.book__coverInner { text-align: center; display: grid; gap: 1rem; }
.book__coverTitle {
  margin: 0;
  font-size: clamp(1.75rem, 5vw, 3.5rem);
  letter-spacing: clamp(0.15em, 2vw, 0.4em);
}
.book__coverSub {
  margin: 0;
  font-size: var(--fs-small);
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.book__plate {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(0.5rem, 1.5vw, 1rem);
}
/* Nothing in the book is cropped — a plate fits its page whole. */
.book__plate img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
}
.book__plate--type span {
  font-size: clamp(1.5rem, 4vw, 2.75rem);
  letter-spacing: 0.12em;
  text-align: center;
  color: var(--fg-dim);
}

.book__head { margin-bottom: clamp(1rem, 2.5vw, 1.75rem); }
.book__eyebrow {
  font-size: var(--fs-small);
  letter-spacing: 0.25em;
  text-transform: uppercase;
  color: var(--fg-dim);
  margin-bottom: 0.6rem;
}
.book__title { margin: 0; font-size: clamp(1.25rem, 2.8vw, 2rem); letter-spacing: 0.04em; }
.book__sub { margin-top: 0.5rem; color: var(--fg-dim); font-size: var(--fs-small); }

.book__prose {
  flex: 1 1 auto;
  min-height: 0;
  /* Never scrolls: paginateBook() reflows anything too long onto a
     continuation page, so what's on the page is all of what's on the page. */
  overflow: hidden;
}
.book__prose p {
  font-size: clamp(0.85rem, 1.15vw, 1rem);
  /* A book column is narrow, so justification needs hyphenation behind it or
     it opens rivers. */
  hyphens: auto;
}
.book__open { margin-top: auto; }

.book__page--blank { background: var(--bg); }
.book__page--colophon {
  align-items: center;
  justify-content: center;
}
.book__page--colophon p {
  margin: 0;
  text-align: center;
  font-size: var(--fs-small);
  letter-spacing: 0.25em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.book__nav { align-self: center; }
.book__nav[disabled] { opacity: 0.12; cursor: none; }
.book__nav[data-book="prev"] { grid-area: prev; }
.book__nav[data-book="next"] { grid-area: next; }
.book__counter {
  grid-area: counter;
  text-align: center;
  margin-top: clamp(0.75rem, 2vw, 1.25rem);
  font-size: var(--fs-small);
  letter-spacing: 0.3em;
  color: var(--fg-dim);
}

/* On a phone the book is a single leaf: the spine sits at the left edge and
   pages turn away off-frame, so nothing is ever shown at half legibility. */
@media (max-width: 760px) {
  /* The arrows drop under the book so the single leaf gets the full width
     instead of being squeezed between them. */
  .book-wrap {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "book book book" "prev counter next";
  }
  .book {
    aspect-ratio: auto;
    height: min(78vh, 600px);
    max-height: none;
    overflow: hidden;
  }
  .book__counter { margin-top: 0; }
  .book__sheets { transform: none; }
  .book--open .book__sheets { transform: none; }
  .book__sheet, .book__flow { left: 0; width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .book__sheet { transition: opacity 0.3s linear; }
  .book__sheet.flipped { transform: none; opacity: 0; pointer-events: none; }
  .book__sheets, .book--open .book__sheets { transform: none; transition: none; }
  .book__sheet { left: 0; width: 100%; }
  .book__face--back { display: none; }
}

/* ---------- Ticker rail ----------
   A gallery that drifts of its own accord instead of waiting to be paged —
   the same continuous travel as the tool ticker and the home strip, run
   slower (speed is set in js/app.js from the track's own width). No counter:
   the loop is endless, so there's no page to report. */
.gallery-wrap--ticker { padding-bottom: 0; }
.gallery-wrap--ticker .gallery-counter { display: none; }
.gallery--ticker {
  display: block;
  overflow: hidden;
  scroll-snap-type: none;
}
.gallery__track {
  display: flex;
  align-items: center;
  gap: clamp(1rem, 3vw, 2.25rem);
  width: max-content;
  animation: marquee 120s linear infinite;
}
/* Hold it still so an image can't slide out from under a click. */
.gallery--ticker:hover .gallery__track { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .gallery__track { animation: none; }
  .gallery--ticker { overflow-x: auto; }
}

/* ---------- Project row ----------
   A project introduced on a category page: the work as a 16:9 rectangle with
   its title and copy beside it on a shared centre line. One arrangement for
   every project intro, so the pages stack evenly instead of alternating
   between a stranded paragraph and a disc adrift in the middle. */
.section--project { padding-block: clamp(2rem, 5vw, 3.5rem); }
.project-row {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3.5rem);
  max-width: 1100px;
}
.project-row__media { display: block; width: 100%; }
.project-row__body {
  display: grid;
  /* minmax(0, 1fr), not the implicit auto track: an auto track takes the
     width of its widest unbreakable thing, so one long word in a heading
     was setting the width of the whole copy column. */
  grid-template-columns: minmax(0, 1fr);
  gap: 0.6rem;
  align-content: center;
}
/* “Trendwatching” is 378px at heading size, and the copy column beside a
   picture is 307px at tablet width. The word won: the column grew past its
   share of the grid and took the page with it, which is the one thing that
   must never happen — a sideways scrollbar on a page that has no sideways
   content. The heading hyphenates instead, and breaks as a last resort. */
.project-row__body .section__heading {
  hyphens: auto;
  overflow-wrap: break-word;
}
.project-row__body .section__heading,
.project-row__body .section__subheading,
.project-row__body .section__eyebrow { margin: 0; }
.project-row__body .prose { margin: 0; max-width: none; }
.project-row__go {
  display: block;
  margin-top: 0.75rem;
  color: var(--fg);
  opacity: 0.48;
  transition: opacity 0.3s var(--ease), transform 0.45s var(--ease);
}
.project-row:hover .project-row__go { opacity: 1; transform: translateX(8px); }
.project-row__go svg {
  display: block;
  width: 31px;
  height: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* A project with no still of its own carries its name instead of a borrowed
   picture, the same way the book sets a title plate. */
.project-card__frame--plate {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
}
.project-card__frame--plate span {
  font-size: clamp(1.1rem, 2.4vw, 1.75rem);
  letter-spacing: 0.1em;
  text-align: center;
  color: var(--fg-dim);
}

@media (max-width: 760px) {
  .project-row { grid-template-columns: 1fr; }
}

/* A rail that fits without scrolling sits centred rather than pinned left
   with dead space beside it. */
.gallery-wrap--static .gallery { justify-content: center; }

/* ---------- Muted section ----------
   Reference material that shouldn't outrank the work: it keeps its place in
   the reading order but steps back, so the projects carry the page. */
.section--muted .section__heading {
  font-size: var(--fs-4);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
.section--muted .triad__item h3 { font-size: var(--fs-small); opacity: 0.84; }
.section--muted .triad__item p { color: var(--fg-dim); font-size: var(--fs-small); }

/* The contact form's honeypot. Off-screen rather than display:none, because
   bots skip fields that are hidden that way and fill ones that merely sit out
   of sight — which is the whole point of it. Takes no layout space. */
.contact-form .hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ---------- An embed beside its explanation ----------
   The player is held to the width of a card on the home strip rather than
   given the column: it is a way in to the channel, not the reading. The
   writing takes the rest of the line and the two sit on one centre. */
/* A contained page holds its text to 640px, which is right for one column
   and wrong for two: with the player taking 320 of it the writing was left
   at 30 characters a line, well under the 45–75 the rest of the site keeps
   to. This row steps outside that cap — and only this row — so both halves
   get a readable measure.

   Only while the two are actually side by side. Below that the row stacks
     and needs no extra width, and the negative margin would turn positive
     and shove the section off the page. */
@media (min-width: 721px) {
  .page--contained .section--media {
    --bleed: min(900px, calc(100vw - var(--sbw, 0px) - 2 * clamp(1.25rem, 5vw, 3rem)));
    width: var(--bleed);
    margin-inline: min(0px, calc((640px - var(--bleed)) / 2));
  }
}

.media-row {
  display: grid;
  grid-template-columns: minmax(180px, 320px) minmax(0, 1fr);
  align-items: center;
  gap: clamp(1.25rem, 4vw, 2.5rem);
}
.media-row__media .embed-frame { margin: 0; }
.media-row__body { display: grid; gap: 0.45rem; align-content: center; }
.media-row__body .section__heading,
.media-row__body .section__eyebrow { margin: 0; text-align: left; }
.media-row__body .prose {
  margin: 0;
  max-width: none;
  text-align: left;
}
/* Below this the two would each be too narrow to be worth the line, so the
   player goes back on top of its own writing. */
@media (max-width: 720px) {
  .media-row { grid-template-columns: 1fr; }
  .media-row__body .prose { text-align: left; }
}

/* ---------- Self-introduction ----------
   The opening screen's own two lines, reused on a page that would otherwise
   ask for a message before saying who is being written to. The type comes
   from css/intro.css (.intro-role / .intro-stance) so there is one
   definition of this voice, not two that drift. */
.self-intro {
  display: grid;
  justify-items: center;
  gap: 0.85rem;
  margin-bottom: clamp(2rem, 4.5vw, 3rem);
  text-align: center;
}
/* On the page the sphere isn't behind it, so it needs no shade and no
   shadow — just the type, at full strength. */
.self-intro .intro-role {
  color: var(--fg);
  opacity: 0.86;
  text-shadow: none;
}
.self-intro .intro-stance {
  color: var(--fg-dim);
  text-shadow: none;
}

/* ---------- Rotating statement ----------
   All three occupy the same grid cell, so the block is as tall as the
   longest and nothing below it moves when they change. The arrows are the
   site's own bare marks, and the change is a cross-fade like every other
   state here. */
.rotator {
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 2vw, 1.25rem);
  margin-top: 1.5rem;
}
.rotator__stage {
  display: grid;
  flex: 1 1 auto;
  min-width: 0;
}
.rotator__item {
  grid-area: 1 / 1;
  margin: 0;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.5s var(--ease), visibility 0.5s var(--ease);
}
.rotator__item.is-current { opacity: 0.82; visibility: visible; }
.rotator:hover .rotator__item.is-current { opacity: 1; }

.rotator__nav {
  flex: 0 0 auto;
  background: none;
  border: none;
  /* 44x44 of hit area around a mark that stays small — measured, because
     padding alone left these at 32x30 on a phone. */
  display: grid;
  place-items: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  color: var(--fg);
  opacity: 0.44;
  transition: opacity 0.35s var(--ease);
}
.rotator__nav:hover,
.rotator__nav:focus-visible { opacity: 1; }
.rotator__nav svg {
  display: block;
  width: clamp(20px, 2.5vw, 28px);
  height: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

@media (prefers-reduced-motion: reduce) {
  /* No cross-fade, and js/app.js does not advance it on its own either. */
  .rotator__item { transition: none; }
  .rotator__nav { transition: none; }
}

/* ---------- Contact links ----------
   The email and the Linktree, set as type on the centre line — no bullets,
   no boxes, marked by tracking and opacity like every other link on the
   site. These replace the dead "@akigato · Linktree" plain text. */
.contact-links {
  list-style: none;
  margin: 1.6rem 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 0.6rem 1.8rem;
}
.contact-links a {
  /* 44px of hit area. These were 15px tall — the smallest targets left on the
     site, on the one page whose whole job is to be tapped. Padding only. */
  display: inline-block;
  padding-block: 0.85rem;
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  opacity: 0.76;
  transition: opacity 0.3s var(--ease);
}
.contact-links a:hover,
.contact-links a:focus-visible { opacity: 1; }

/* A full email address at tracking is a wide word. On a phone it sat flush to
   both edges, so the tracking eases off and the two links stack rather than
   filling the line corner to corner. */
@media (max-width: 480px) {
  .contact-links { gap: 0.9rem; flex-direction: column; }
  .contact-links a { letter-spacing: 0.12em; }
}

/* ---------- Curriculum vitae ----------
   The one thing this page asks a visitor to do, so it leads: set above the
   email and the LinkedIn rather than under them, and given the only box on
   the page. The box is a hairline and nothing else — no fill, no rounding,
   thin type at the site's tracking — so it reads as emphasis in the site's
   own language rather than as a button borrowed from somewhere else. It
   carries no arrow: the frame already says "this goes somewhere", and a mark
   inside it pushes the word off the centre of its own box. */
.cv-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.8rem 1.2rem;
  margin-top: clamp(1.75rem, 4vw, 2.5rem);
}
.cv-links .cv-link { margin-top: 0; }

.cv-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The padding is the tap target — 50px tall at the smallest. */
  padding: 1.05rem clamp(1.9rem, 4.5vw, 3rem);
  border: 1px solid color-mix(in srgb, var(--fg) 42%, transparent);
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg);
  opacity: 0.88;
  transition:
    opacity 0.3s var(--ease),
    border-color 0.3s var(--ease),
    background-color 0.3s var(--ease);
}
.cv-link:hover,
.cv-link:focus-visible {
  opacity: 1;
  border-color: color-mix(in srgb, var(--fg) 80%, transparent);
  background: color-mix(in srgb, var(--fg) 7%, transparent);
}
/* The contact links follow the box, so the clear space belongs between them
   rather than above the pair. */
.cv-links + .contact-links { margin-top: clamp(1.5rem, 3.5vw, 2.25rem); }

.page--centered .cv-link { justify-self: center; }

/* ---------- The two ways in, under the opening screen ----------
   A visitor who has just read the intro has two questions: show me the work,
   and who is this. These answer both without asking anyone to browse. Both
   are the hairline box the contact page uses for the CV — no fill, no
   rounding, thin type at the site's tracking — so this is the site's existing
   language rather than a pair of buttons borrowed from somewhere else. */
.home-cta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.9rem 1.25rem;
  margin: 0 auto clamp(2.5rem, 6vw, 4rem);
}
.home-cta__box {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The padding is the tap target: 50px at the smallest. */
  padding: 1.05rem clamp(1.6rem, 4vw, 2.6rem);
  background: none;
  border: 1px solid color-mix(in srgb, var(--fg) 40%, transparent);
  font: inherit;
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg);
  cursor: pointer;
  text-align: center;
  transition: border-color 0.35s var(--ease), background-color 0.35s var(--ease),
    opacity 0.35s var(--ease);
}
.home-cta__box:hover,
.home-cta__box:focus-visible {
  opacity: 1;
  border-color: color-mix(in srgb, var(--fg) 80%, transparent);
  background: color-mix(in srgb, var(--fg) 7%, transparent);
}
/* Only the question breathes. Two boxes pulsing at each other is a fairground,
   and the CV does not need to ask — the person who wants it is already looking
   for it. */
.home-cta__box--ask {
  animation: home-cta-breathe 5s ease-in-out infinite;
}
@keyframes home-cta-breathe {
  0%, 100% { opacity: 0.78; border-color: color-mix(in srgb, var(--fg) 28%, transparent); }
  50% { opacity: 1; border-color: color-mix(in srgb, var(--fg) 52%, transparent); }
}
.home-cta__box--ask:hover,
.home-cta__box--ask:focus-visible { animation: none; }
/* display:inline-flex would otherwise beat the [hidden] default and leave the
   question on the page after it has been taken up. */
.home-cta__box[hidden] { display: none; }

/* ---------- Where to start ----------
   Three questions, then one link. An option is the same object as a result
   row, because choosing one does the same thing. */
.router {
  max-width: 34rem;
  margin: 0 auto clamp(2.5rem, 6vw, 4rem);
}
.router__stage { transition: opacity 0.32s var(--ease); }
.router__stage.is-fading { opacity: 0; }
.router__q {
  margin: 0 0 0.35rem;
  line-height: 1.5;
  text-wrap: pretty;
}
.router__row {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  width: 100%;
  padding: 1rem 0;
  border: 0;
  border-top: 1px solid color-mix(in srgb, var(--fg) 13%, transparent);
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  opacity: 0.82;
  transition: opacity 0.35s var(--ease);
}
.router__row:hover,
.router__row:focus-visible { opacity: 1; }
.router a.router__row:last-of-type,
.router button.router__row:last-of-type {
  border-bottom: 1px solid color-mix(in srgb, var(--fg) 13%, transparent);
}
.router__name { letter-spacing: 0.05em; }
.router__row--lead .router__name {
  font-size: clamp(1.05rem, 1rem + 0.4vw, 1.3rem);
  letter-spacing: 0.06em;
}
.router__meta {
  margin-left: auto;
  font-size: var(--fs-small);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-dim);
  white-space: nowrap;
}
.router .link-go {
  flex: 0 0 auto;
  width: 26px;
  align-self: center;
  transform: translateX(0);
  transition: transform 0.35s var(--ease);
}
.router__row:hover .link-go { transform: translateX(4px); }
.router .link-go svg {
  display: block;
  width: 100%;
  height: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.router__why {
  margin: 0.9rem 0 0;
  font-size: var(--fs-small);
  color: var(--fg-dim);
  line-height: 1.7;
  text-wrap: pretty;
}
.router__or {
  margin: 1.6rem 0 0.35rem;
  font-size: 0.75rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
/* The same NN / NN counter the lightbox and the rails are steered by. */
.router__count {
  margin: 0.9rem 0 0;
  text-align: center;
  font-size: 0.7rem;
  letter-spacing: 0.26em;
  color: var(--fg-dim);
  opacity: 0.76;
}
.router__again { margin: 1.4rem 0 0; text-align: center; }
.router__again button {
  background: none;
  border: 0;
  padding: 0.4rem 0;
  font: inherit;
  font-size: 0.7rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: var(--fg-dim);
  cursor: pointer;
  transition: color 0.35s var(--ease);
}
.router__again button:hover,
.router__again button:focus-visible { color: var(--fg); }

@media (max-width: 480px) {
  /* Two boxes side by side squeeze to two lines each; stacked they read. */
  .home-cta { flex-direction: column; align-items: stretch; }
}

@media (prefers-reduced-motion: reduce) {
  .home-cta__box--ask { animation: none; opacity: 0.92; }
  .router__stage, .router__row, .router .link-go, .router__again button { transition: none; }
  .router__row:hover .link-go { transform: none; }
}

/* ---------- A section's copy falls from its title ----------
   The heading, its eyebrow and the paragraph beneath it share one centred
   axis under the page title, so the copy reads as coming down off the title
   rather than hanging off to one side of a wide page. Only sections that own
   their column: a project row's copy stays left, beside its picture, and the
   editorial page keeps its own measured layout. */
.page:not(.page--editorial) > .section:not(.section--project) > .section__heading,
.page:not(.page--editorial) > .section:not(.section--project) > .section__subheading,
.page:not(.page--editorial) > .section:not(.section--project) > .section__eyebrow {
  text-align: center;
}
.page:not(.page--editorial) > .section:not(.section--project) > .prose {
  margin-inline: auto;
}

/* ---------- Letter-by-letter reveal ----------
   Text arrives a character at a time when it scrolls into view. Characters
   are only split once, by wireReveal(); before that the element is hidden, so
   nothing flashes un-animated first. The delay is capped so a long paragraph
   finishes promptly instead of trickling. */
/* Never hide the block: if wireReveal() never runs for an element - a book
   page that is rotated away, JS disabled, a failed observer - the copy has to
   be readable anyway. Only the split characters start invisible, and they
   only exist once the split has happened. */
/* One unbreakable box per word, so a line can only ever break between words
   — the characters inside it animate independently. */
.reveal .rv-w { display: inline-block; white-space: nowrap; }
.reveal .rv {
  display: inline-block;
  opacity: 0;
  transform: translateY(0.18em);
  animation: rv-in 0.45s var(--ease) forwards;
  animation-delay: min(calc(var(--i) * 9ms), 900ms);
}
@keyframes rv-in {
  to { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .reveal, .reveal:not(.revealed) { opacity: 1; }
  .reveal .rv { opacity: 1; transform: none; animation: none; }
}

/* ---------- One margins box ----------
   Every block resolves to the same column: full width of the content box, or
   a measure centred inside it. A block that is narrower AND left-aligned is
   the one thing that reads as misaligned, which is what the project row used
   to do — 1100px pinned left inside a 1304px box. */
.project-row { max-width: none; }

/* The title block holds its copy directly, at a readable measure, centred
   under the title rather than a screen below it. */
.hero__intro {
  margin-top: clamp(1.5rem, 4vw, 2.5rem);
  margin-inline: auto;
  text-align: left;
}

/* ---------- Row media hugs its picture ----------
   A fixed 16:9 frame letterboxes anything that isn't 16:9 — the 8th Day Data
   infographic is 1891x3261, so it sat in a narrow strip between two empty
   bars. The frame now takes the shape of the work instead: the image is
   capped in height, never cropped, and the box is exactly the picture. */
/* Every thumbnail is a filled horizontal 16:9 box. Filling a fixed frame
   means cropping, so the focal point is chosen per image with
   `project.focus` rather than always taking the middle. */
.project-row__media .project-card__frame {
  aspect-ratio: 16 / 9;
  display: block;
  width: 100%;
  background: none;
}
.project-row__media .project-card__layer.active {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* ---------- Live preview thumbnail ----------
   The project's own page, rendered at desktop width and scaled down to the
   card. Inert: no pointer events, not focusable, hidden from assistive tech —
   the card's own link is what you click. */
.project-card__frame--preview {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
.project-card__frame--preview iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 1440px;
  height: 810px;
  border: 0;
  transform-origin: top left;
  transform: scale(var(--preview-scale, 0.3));
  pointer-events: none;
}
/* The row's frame hugs its picture; a preview has no picture to hug, so it
   keeps the 16:9 box. */
.project-row__media .project-card__frame--preview { aspect-ratio: 16 / 9; display: block; }

/* A full-frame embed: the project fills the page under the nav, edge to edge,
   with nothing around it — the piece IS the page, not something inset in one. */
/* An embed-only page is nothing but the piece: no shell padding at all, so
   it begins immediately under the ticker and fills what is left. */
body.page-embedfull #app-content { padding: 0; max-width: none; }
body.page-embedfull .section { padding-block: 0; }
body.page-embedfull .hero { padding-inline: clamp(1.25rem, 5vw, 3rem); }
.embed-frame--full {
  width: 100%;
  margin-block: 0;
  height: calc(100vh - var(--nav-h));
}
/* The whole row is the link now; the glow follows the row's hover, not just
   the picture's. */
a.project-row { display: grid; }
.project-row:hover .project-card__frame {
  box-shadow: 0 0 var(--glow-blur) 0 color-mix(in srgb, var(--fg) 30%, transparent);
}
/* A circular picture sits in a circular frame: square, the size of the
   picture, rounded to a true circle — so the glow is a halo around the disc
   rather than a lit rectangle behind it. */
.project-row__media .project-card__frame--round {
  aspect-ratio: 1 / 1;
  width: min(100%, 17rem);
  margin-inline: auto;
  border-radius: 50%;
  overflow: hidden;
}

/* ---------- Note ----------
   Reference that belongs to a phrase rather than to the page. Its name in the
   copy opens it over the text; clicking anywhere outside closes it. */
/* A phrase in the copy that goes somewhere wears the same mark as a phrase
   that opens a note: a hairline under the words, lifting to full on hover.
   One declaration for both — a link in the prose and a note trigger are the
   same promise to a reader, and two rules would eventually become two
   different ideas of what "this does something" looks like. No colour: the
   site has one ink, and `a { color: inherit }` above already takes the
   browser's underline off. */
.note-trigger,
.prose a {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: var(--fg);
  letter-spacing: inherit;
  border-bottom: 1px solid color-mix(in srgb, var(--fg) 35%, transparent);
  transition: border-color 0.3s var(--ease);
}
.note-trigger:hover,
.note-trigger:focus-visible,
.prose a:hover,
.prose a:focus-visible { border-bottom-color: var(--fg); }

/* `display: grid` below beats the UA stylesheet's `[hidden] { display: none }`,
   so the hidden attribute stopped hiding anything. Restating it against the
   class wins on specificity (0,2,0 over 0,1,0) and the panel hides again. */
.note[hidden] { display: none; }

.note {
  position: absolute;
  inset: 0;
  z-index: 6;
  display: grid;
  place-items: center;
  padding: clamp(1rem, 4vw, 2.5rem);
  opacity: 0;
  transition: opacity 0.32s var(--ease);
}
.note.open { opacity: 1; }
.note__panel {
  width: min(100%, 1100px);
  background: var(--bg);
  padding: clamp(1.25rem, 3vw, 2.25rem);
  transform: translateY(6px);
  transition: transform 0.32s var(--ease);
}
.note.open .note__panel { transform: translateY(0); }
.note__heading {
  margin: 0 0 1.25rem;
  text-align: center;
  font-size: var(--fs-4);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg-dim);
}
.note .triad { margin-block: 0; }

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

/* Inside the book a page clips its own overflow, so a spotlight that bleeds
   past the text would both draw a hard edge and inflate the page's
   scrollHeight — which is exactly what paginateBook() measures to decide
   whether an article fits. Keep it inside the text block there. */

/* ---------- Phone mockup ----------
   A phone app belongs in a handset, not stretched across the page. The frame
   is the only rounded, outlined box on the site and it is here because the
   object itself has those: a bezel and a screen radius. The demo is
   responsive, so at phone width it lays out exactly as the app does. */
.embed-stage {
  display: grid;
  place-items: center;
  min-height: min(78vh, 820px);
  padding-block: clamp(1.5rem, 4vw, 3rem);
}
.device-frame {
  /* A handset, not a rounded rectangle. The screen renders at a real 390x844
     so the app lays out as it does on a device; only the iframe is scaled,
     and the bezel takes the scaled size as its own layout box, because
     scaling the whole frame with a transform leaves the box unscaled and the
     stage then centres the wrong rectangle. Every dimension is multiplied by
     the same scale so the proportions hold at any size. */
  --s: var(--device-scale, 1);
  --bezel: 13px;
  --screen-w: 390px;
  --screen-h: 844px;
  position: relative;
  width: calc((var(--screen-w) + var(--bezel) * 2) * var(--s));
  height: calc((var(--screen-h) + var(--bezel) * 2) * var(--s));
  flex: none;
  padding: calc(var(--bezel) * var(--s));
  border-radius: calc(62px * var(--s));
  /* Brushed rim: light catches the top-left, the body stays near-black. */
  background: linear-gradient(152deg, #3a3a3d 0%, #161618 18%, #0b0b0c 52%, #1d1d21 82%, #38383c 100%);
  box-shadow:
    inset 0 0 0 calc(1.5px * var(--s)) rgba(255, 255, 255, 0.14),
    inset 0 calc(1px * var(--s)) calc(3px * var(--s)) rgba(255, 255, 255, 0.1),
    0 calc(2px * var(--s)) calc(8px * var(--s)) rgba(0, 0, 0, 0.5),
    0 calc(34px * var(--s)) calc(80px * var(--s)) rgba(0, 0, 0, 0.6);
}
/* Dynamic island */
.device-frame::before {
  content: "";
  position: absolute;
  top: calc(calc(var(--bezel) + 11px) * var(--s));
  left: 50%;
  transform: translateX(-50%);
  width: calc(112px * var(--s));
  height: calc(33px * var(--s));
  border-radius: calc(20px * var(--s));
  background: #000;
  z-index: 3;
  pointer-events: none;
}
/* The screen edge: a hairline so the display reads as glass set into metal. */
.device-frame::after {
  content: "";
  position: absolute;
  inset: calc(var(--bezel) * var(--s));
  border-radius: calc(49px * var(--s));
  box-shadow: inset 0 0 0 calc(1px * var(--s)) rgba(0, 0, 0, 0.55);
  z-index: 2;
  pointer-events: none;
}
/* The screen holds a status strip so the app clears the island, the way it
   would sit below the status bar on a real handset. The strip takes the
   app’s own paper colour so there is no seam. */
.device-screen {
  --status: 46px;
  --screen-bg: #f4f0ea;
  position: relative;
  width: var(--screen-w);
  height: var(--screen-h);
  background: var(--screen-bg);
  border-radius: 49px;
  overflow: hidden;
  transform: scale(var(--s));
  transform-origin: top left;
}
.device-screen iframe {
  display: block;
  position: absolute;
  top: var(--status);
  left: 0;
  width: var(--screen-w);
  height: calc(var(--screen-h) - var(--status));
  border: 0;
}

.embed-stage { overflow: hidden; }


/* ---------- Text hover ----------
   A block of copy lifts on hover the way a nav or footer link does: opacity
   alone, nothing else. A pointer-tracked light was tried here and read as a
   lamp shining on the page rather than as the text responding. */
/* ---------- Data callout ----------
   A figure the research actually states, set large with its year and source.
   Not a plotted chart: a chart needs a series, and the study gives one year. */
/* Two figures side by side only read as a pair if their three lines sit on
   the same baselines. Laid out as independent blocks they don't: a value
   that wraps to a second line pushes its own label and source down and
   nothing lines up across the row. The three rows are declared on the
   container and each stat takes them as a subgrid, so the value row is as
   tall as the tallest value and every label starts level. */
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  grid-template-rows: auto auto auto;
  column-gap: clamp(1.5rem, 4vw, 3rem);
  /* The rows are spaced by the elements' own margins, not by the grid. */
  row-gap: 0;
  margin-block: clamp(2rem, 5vw, 3rem);
}
/* Six figures split four and two at desktop width, leaving a lopsided second
   row. Three and three keeps both rows full. */
@media (min-width: 900px) {
  .stats:has(> .stat:nth-child(6):last-child) { grid-template-columns: repeat(3, 1fr); }
}
/* Two figures across a full-width grid sat at opposite edges of the page
   and stopped reading as a pair. A pair is held to a centred measure. */
.stats:has(> .stat:nth-child(2):last-child) { max-width: 40rem; margin-inline: auto; }
/* The grid has no row gap, so the inner rows of each figure stay tight.
   When figures wrap onto a second row, each figure keeps its own space
   below instead, or the next row's value lands on this row's source line. */
.stats:has(> .stat:nth-child(4)) .stat { margin-bottom: clamp(2rem, 5vw, 3.25rem); }
.stat {
  margin: 0;
  text-align: center;
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}
.stat__value {
  font-size: clamp(2rem, 5vw, 3.25rem);
  line-height: 1.1;
  letter-spacing: 0.02em;
  /* A value that needs two lines grows upward, so the distance from the
     value to its label is the same in every column. */
  align-self: end;
  text-wrap: balance;
}
.stat__label {
  margin-top: 0.85rem;
  /* A label left to run the full column width breaks into a different
     number of lines in each one, which unbalances the row again. */
  max-width: 32ch;
  margin-inline: auto;
  text-wrap: balance;
  font-size: var(--fs-small);
  color: var(--fg-dim);
  line-height: 1.5;
}
.stat__source {
  margin-top: 0.6rem;
  font-size: var(--fs-small);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  /* No extra fade: dim at 0.7 measured 3.5:1 on black, under the WCAG AA
     line for small type. The dim colour alone is about 6.4:1. */
  color: var(--fg-dim);
}


/* A continued page is a resumption, not a new article: its title and its
   "continued" mark are set 40% smaller than the ones that open a piece. */
.book__page--cont .book__title { font-size: calc(clamp(1.25rem, 2.8vw, 2rem) * 0.6); }
.book__page--cont .book__eyebrow { font-size: calc(var(--fs-small) * 0.6); }

/* ---------- Device alongside its explanation ----------
   Handset one side, the words the other, on a shared centre line — the same
   arrangement as a project row so the pages read consistently. */
.section--device { padding-block: clamp(2rem, 5vw, 3.5rem); }
.device-row {
  display: grid;
  /* The handset leads. The writing is pinned to a narrow measure so the
     device is the wider of the two — the screen renders at a true 390px and
     is never upscaled past 1:1, which would only blur the app. */
  /* 440px, not 380px: measured against Montserrat's own metrics, 380 set
     about 39 characters to the line and 420 about 43 — both under the 45ch
     floor this site sets itself, on the longest single block of body copy
     anywhere on it. 440 clears it. The handset still takes ~800px of the
     row, so it stays comfortably the wider of the two. */
  grid-template-columns: minmax(0, 1fr) minmax(240px, 440px);
  align-items: center;
  gap: clamp(1.5rem, 5vw, 4rem);
}
.device-row .embed-stage {
  min-height: 0;
  padding-block: 0;
}
.device-row__body {
  display: grid;
  gap: 0.6rem;
  align-content: center;
}
.device-row__body .section__heading,
.device-row__body .section__eyebrow { margin: 0; text-align: left; }
.device-row__body .prose { margin: 0; max-width: none; }
/* Set left, not justified. Justification is the site's default for body copy,
   but it needs room: at this measure inter-word spacing opens the rivers
   designrules.md warns about, and automatic hyphenation makes it worse. The
   rule elsewhere stands; this column is too narrow to carry it. */
.device-row__body .prose p {
  text-align: left;
  hyphens: manual;
  -webkit-hyphens: manual;
  word-spacing: normal;
}

@media (max-width: 860px) {
  .device-row { grid-template-columns: 1fr; }
  .device-row__body { text-align: left; }
}

/* What the demo is, and what it is not — set quietly under the handset so the
   mockup never implies the shipped app. */
.device-note {
  margin: clamp(0.9rem, 2vw, 1.25rem) auto 0;
  max-width: 44ch;
  text-align: center;
  font-size: var(--fs-small);
  line-height: 1.6;
  color: var(--fg-dim);
}

/* ---------- Focus, part two ----------
   `:focus-visible { opacity: 1 }` further up is outranked by component rules
   like `.nav-links a { opacity: 0.6 }` — same specificity class, but those
   carry a type selector too. Restated here with one of its own so it ties,
   and last in the file so order decides it. Focus now brightens the item the
   way hover does; before, a keyboard user got the ring on a dimmed link. */
a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible,
summary:focus-visible { opacity: 1; }

/* ---------- The second level ----------
   The pages of the discipline you are in, named under the title. It is an
   index, not a masthead: it does not stick, it does not sit in the fixed
   header, and it carries no rule, box or fill — the nav above is already the
   site's one piece of navigation chrome and a second bar would read as a
   second site.

   Set like the top row and dimmed further, because this is the quieter of
   the two: same uppercase, same tracking, one step down in presence. The
   page you are on is a <span> rather than a link, at full strength — the
   only item here that is marked, and marked the way everything on this site
   is marked, by strength rather than by a box.

   NO SEPARATOR BETWEEN ITEMS, and the gap is wide enough not to need one.
   A hairline was drawn in the gap first, on the theory that a mark belonging
   to the space between two items could never land at the start of a line.
   It can: the gap is still there when the row wraps, so at 375px the second
   line began with a rule standing in front of VALLAE with nothing to its
   left. Spacing separates these perfectly well, and it cannot wrap wrong. */
.sub-nav {
  margin-block: clamp(1.5rem, 4vw, 2.75rem) clamp(0.5rem, 2vw, 1rem);
}
.sub-nav ul {
  display: flex;
  flex-wrap: wrap;
  /* The row wraps — unlike the nav, which must never put a label on a second
     line, this is a list of titles of unpredictable length and a discipline
     may hold six. Wrapping is what a list does. */
  gap: 0 clamp(1.1rem, 3vw, 2.25rem);
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--fs-small);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
/* 0.875rem of padding against a ~16px line gives a 44px target — the same
   number the top row was raised to, and the minimum WCAG 2.5.5 asks for.
   Padding only: the items sit on their own line either way, so nothing
   moves. */
.sub-nav a,
.sub-nav span {
  display: inline-block;
  padding-block: 0.875rem;
  /* 0.71, the measured faint tier — it was 0.45, written before the
     readability pass, and at a thin weight that read as texture. */
  opacity: 0.71;
  transition: opacity 0.25s var(--ease);
}
.sub-nav a:hover,
.sub-nav a:focus-visible { opacity: 0.85; }
.sub-nav [aria-current="page"] { opacity: 1; }
/* The way back up reads as the heading of the row rather than one more entry
   in it, so it carries a little more weight than its neighbours. */
.sub-nav__up a { opacity: 0.7; }

/* When a row's copy links somewhere itself the row stops being one big link
   and the heading takes the job (see projectRowHtml). It is a title, not a
   phrase in a sentence, so it does not wear the prose hairline — it lifts,
   which is the move the rest of the site's headings and nav already make. */
.project-row__body .section__heading a {
  opacity: 0.88;
  transition: opacity 0.25s var(--ease);
}
.project-row__body .section__heading a:hover,
.project-row__body .section__heading a:focus-visible { opacity: 1; }

/* ---------- Square modules ----------
   The fourth shape, and the only one that is not a picture. A disc is one
   image, a 16:9 card a project, a 16:9 slide a page of a deck; a square is a
   thing you can take rather than a thing you can look at. Three studio
   courses, any one of which stands alone and none of which requires another
   — a row of rectangles would have made the widest title look like the
   biggest course, and a square has no long side to lean on.

   Three across, one per line on a phone. `auto-fit` with a floor rather than
   a fixed three, so the middle widths put two on a line instead of squeezing
   three into something that is no longer square. */
.modules {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
  gap: clamp(1rem, 2.5vw, 1.75rem);
  margin-block: clamp(1.5rem, 4vw, 2.5rem);
}
/* min-width: 0 on both, or the grid item refuses to go below the widest
   unbreakable thing inside it — "Neuroethical" set a floor wider than its
   own track and the page scrolled sideways, which is the one fault the
   responsiveness rule has no tolerance for. */
.module-cell { display: grid; gap: 0.75rem; align-content: start; min-width: 0; }

/* The hairline is deliberate and is spent knowingly: "no invented outlines"
   holds everywhere else, and this is the second place on the site where a
   box earns one — the first being the CV link. A module is an object being
   offered, and an object offered with nothing around it is a paragraph.
   It is also the idiom the teaching documents themselves use: rules, never
   card fills. No radius, no fill, one hairline. */
.module {
  aspect-ratio: 1;
  min-width: 0;
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  gap: 0.5rem;
  padding: clamp(1.1rem, 2.4vw, 1.6rem);
  border: 1px solid color-mix(in srgb, var(--fg) 16%, transparent);
  transition: border-color 0.4s var(--ease), opacity 0.4s var(--ease);
  opacity: 0.82;
}
.module:hover,
.module:focus-visible {
  border-color: color-mix(in srgb, var(--fg) 42%, transparent);
  opacity: 1;
}
.module__n {
  font-size: var(--fs-small);
  letter-spacing: 0.2em;
  opacity: 0.71;
}
.module__pos {
  font-size: var(--fs-small);
  text-transform: uppercase;
  letter-spacing: 0.2em;
  opacity: 0.84;
}
.module__title {
  font-size: var(--fs-3);
  line-height: 1.18;
  overflow-wrap: break-word;
  hyphens: auto;
  -webkit-hyphens: auto;
  text-wrap: balance;
  align-self: start;
  margin-block-start: 0.35rem;
}
/* The line sits at the foot of the square, under the title's own space, so
   three modules with titles of one, three and two lines still agree about
   where their copy starts reading. */
.module__line {
  align-self: end;
  font-size: var(--fs-small);
  line-height: 1.55;
  opacity: 0.84;
}
.module__go { justify-self: start; }
.module__go svg {
  width: 32px;
  height: 10px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  opacity: 0.5;
  transition: transform 0.4s var(--ease), opacity 0.4s var(--ease);
}
.module:hover .module__go svg { opacity: 1; transform: translateX(6px); }

/* The four weeks, under the square. Hairlines between them and no fill —
   the idiom the teaching documents themselves use, and the same one the
   tile above wears. A rule per week rather than a box per week, so four
   stacked rows still read as one column rather than as four more cards.

   The two-track grid holds the week label clear of its own text: W1 sits in
   a fixed 1.9rem column and the title and description share the second,
   which keeps all four titles starting at one left edge no matter how long
   the labels get. `minmax(0, 1fr)` on that track, never `auto` — an `auto`
   track takes the width of its widest unbreakable word and pushes the
   column, the grid and then the page out sideways. */
.module__weeks {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.55rem;
  min-width: 0;
}
.module__weeks li {
  display: grid;
  grid-template-columns: 1.9rem minmax(0, 1fr);
  column-gap: 0.55rem;
  padding-block-start: 0.55rem;
  border-block-start: 1px solid color-mix(in srgb, var(--fg) 11%, transparent);
  min-width: 0;
}
.module__weeks li:first-child { border-block-start: none; padding-block-start: 0; }
.module__wkn {
  font-size: var(--fs-small);
  letter-spacing: 0.18em;
  opacity: 0.6;
  line-height: 1.5;
}
/* Not a <b>. The site is one font at one weight, and b/strong are among the
   few elements the browser still bolds on its own; a span cannot drift. */
.module__wkt {
  font-size: var(--fs-small);
  line-height: 1.5;
  overflow-wrap: break-word;
}
.module__wkd {
  grid-column: 2;
  font-size: var(--fs-small);
  line-height: 1.5;
  color: var(--fg-dim);
  margin-block-start: 0.1rem;
  overflow-wrap: break-word;
}

/* What they carry out, and who it is for. Label and value on one line, the
   label in the same small caps the rest of the page uses for a field name,
   so the eye can skip the labels and read straight down the values. */
.module__foot {
  margin: 0;
  font-size: var(--fs-small);
  line-height: 1.5;
  color: var(--fg-dim);
}
/* Full ink faded once, never --fg-dim faded again. The label began as
   `opacity: 0.66` inheriting the dim colour of the line it sits on, which is
   the stacked fade the legibility rule forbids: 0.68 × 0.66 lands small caps
   at 4.27:1 on black, under the 4.5 floor. One fade on full ink is 8:1 and
   looks the same. */
.module__footk {
  display: block;
  color: var(--fg);
  text-transform: uppercase;
  letter-spacing: 0.2em;
  opacity: 0.66;
  margin-block-end: 0.15rem;
}

/* The kit and the handbook, outside the tile. They are their own documents
   and their own links — nesting them inside the module's anchor would be an
   <a> inside an <a>, which the parser simply takes apart. */
.module__docs {
  margin: 0;
  font-size: var(--fs-small);
  letter-spacing: 0.06em;
  opacity: 0.71;
}
.module__docs a { border-bottom: 1px solid transparent; transition: border-color 0.3s var(--ease), opacity 0.3s var(--ease); }
.module__docs a:hover,
.module__docs a:focus-visible { border-bottom-color: currentColor; }
.module__docs:hover { opacity: 0.8; }

@media (prefers-reduced-motion: reduce) {
  .module, .module__go svg { transition: none; }
  .module:hover .module__go svg { transform: none; }
}

/* ---------- Square source cards (`square: true` on a gallery) ----------
   The row of things a course is made from: a dossier you can read, a thesis
   you can open, three games you can play. Square for the reason the modules
   above are square — these are things you can take rather than pictures to
   look at — and because a 16:9 row let the widest picture read as the most
   important source, which is not what the row is saying.

   The pictures fill the squares. `contain` was tried first, on the grounds
   that a square `cover` takes the middle out of a portrait study and a wide
   title card alike — but it left every card framed in a band of its own
   ground and the row read as four pictures floating in four boxes rather
   than four boxes. Filled, the row is a row. The one picture that loses
   something to the crop is VALLAE, which is portrait: it is centred, so what
   goes is ceiling and floor rather than the desk the image is about. */
.gallery--square .project-card__frame {
  aspect-ratio: 1;
}
.gallery--square .project-card__frame img {
  object-fit: cover;
  object-position: var(--focus, center);
}
.gallery--square .project-card { flex: 0 0 clamp(13rem, 26vw, 20rem); }

/* A section that opts out of the centred head (`align: "left"`). Centred is
   right for a heading that introduces a page; over a row of columns it is
   not, because three left-aligned blocks under a centred title give the eye
   three different places to start. The selector matches the centring rule
   above carries five classes (.page, two :not() arguments, .section and the
   element class) and this one has to match that count to beat it — four lost
   silently and the head stayed centred. `.section.section--left` makes five,
   and this rule is later in the file, so the tie goes to it. */
.page:not(.page--editorial) > .section.section--left > .section__heading,
.page:not(.page--editorial) > .section.section--left > .section__subheading,
.page:not(.page--editorial) > .section.section--left > .section__eyebrow {
  text-align: left;
}
.page:not(.page--editorial) > .section.section--left > .prose {
  margin-inline: 0;
}
