/* ══════════════════════════════════════════════════════════════════════════
   VOLUME 0 — shared stylesheet

   Everything both pages need: fonts, reset, tokens, page shell, typography,
   the nav bar, the menu overlay, the subscribe block, the footer and the
   scroll-reveal helper.

   Page-specific bands stay in an inline <style> on the page that owns them —
   home sections in index.html, the issue list in archive.html. See CLAUDE.md.

   Band order is the house convention:
     fonts → reset → variables → page shell → components
   Desktop breakpoint is 960px. 20px side padding at 680px+ (was 48).
   ══════════════════════════════════════════════════════════════════════════ */

/* ── Fonts ── */
/* The design uses two families (confirmed from Figma variables):
     Century Schoolbook Pro — display + serif body
     Arial Narrow          — UI, leading text, captions

   Century Schoolbook Pro landed 2026-08-24, Regular and Italic, woff2 with a
   woff fallback. Two things about these declarations:

   Paths are relative to *this stylesheet*, so ../fonts/ — not static/fonts/,
   which is what the placeholder block here used to say. That was correct only
   while this CSS was inline on index.html; from static/css/ it would have
   resolved to /static/css/static/fonts/ and 404'd every face.

   The vendor's own style.css led each src with local("Century Schoolbook Pro
   Regular"). That is dropped on purpose: it renders whatever copy happens to
   be installed on the visitor's machine, so a stale or differently-cut desktop
   version would silently replace the hosted one, and the result stops being
   reproducible. Hosted files only. */
@font-face {
  font-family: "Century Schoolbook Pro";
  src:
    url("../fonts/century-schoolbook-pro-regular.woff2") format("woff2"),
    url("../fonts/century-schoolbook-pro-regular.woff") format("woff");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Caslon Ionic, landed 2026-08-24 — the archive's issue numbers and prices.
   Until now --font-caslon fell through to the Georgia stack, so those two read
   as Century Schoolbook rather than Caslon.

   Shipped as OTF, not woff2: the supplied file is an OTF and there is no woff2
   encoder on this machine. Browsers read OTF fine, it just costs bytes — 165KB
   against roughly 70 for the same face as woff2. `brew install woff2` then
   `woff2_compress` on this file would close that gap and only the url() below
   would change.

   Note the file's own family name is "Caslon Ionic Regular", not the "Caslon
   Ionic" the token asks for. That only matters for local() lookups, which this
   deliberately does not use — the @font-face name is whatever is declared here,
   and hosting it keeps the rendering the same for everyone. */
@font-face {
  font-family: "Caslon Ionic";
  src: url("../fonts/caslon-ionic-regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* A real italic, not a slope. The hero's "Issue 15" and the nav's issue label
   both set italic, and without this face the browser shears the roman — which
   is a different letterform, not the same one tilted. */
@font-face {
  font-family: "Century Schoolbook Pro";
  src:
    url("../fonts/century-schoolbook-pro-italic.woff2") format("woff2"),
    url("../fonts/century-schoolbook-pro-italic.woff") format("woff");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

/* Metric-adjusted fallback. Now that the real faces are hosted this is only
   reached if they fail to load, so it is kept as a net rather than removed —
   it sits after the real family in --font-serif and costs nothing when the
   woff2 arrives. Georgia sets about 10% wider than Century Schoolbook Pro, so
   copy wrapped to an extra line and read as cramped: the issue paragraph
   measured 11 lines / 286px against Figma's 10 lines / 256px at the same 360px
   width. size-adjust: 96% is the lightest correction that reproduced Figma's
   line count (10 lines / 260px). Browsers without size-adjust support simply
   get plain Georgia. */
@font-face {
  font-family: "Century Schoolbook Fallback";
  src: local("Georgia");
  font-style: normal;
  size-adjust: 96%;
}

/* Italic companion for that fallback. Without it the family holds only a
   roman, so a browser asked for italic synthesises a slant off Georgia
   Regular — measurably, not theoretically: the sheared roman keeps the
   roman's advance widths, which is how the fake was spotted. Georgia Italic
   is a distinct cut, so name it explicitly and keep the roman as a last
   resort for systems that ship one file. */
@font-face {
  font-family: "Century Schoolbook Fallback";
  src: local("Georgia Italic"), local("Georgia");
  font-style: italic;
  size-adjust: 96%;
}

/* ── Reset ── */
*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

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

/* ── Variables ── */
:root {
  /* Colors — TODO: most still placeholders; black/white confirmed from
     the hero. --color-pink (#ee3288) is the desktop buy bar, the
     contributor mask fill, and the @handle text. --color-muted was an
     unconfirmed guess (#6b6b6b) until privacy-policy.html's "Last Updated"
     caption gave it a real value, #666666, 2026-08-28. */
  --color-bg: #ffffff;
  --color-text: #000000;
  --color-pink: #ee3288;
  --color-placeholder: #afafaf;
  --color-muted: #666666;
  --color-rule: #e4e4e4;
  --color-accent: #111111;

  /* The nav Buy now flash, which tracks the issue as of 2026-08-24 — so it is
     #ee3288 today and follows --issue-accent at every rollover rather than
     needing its own edit.

     It was #fa47d9 until then: button-vol0.gif's own magenta, sampled off the
     local colour table in that GIF's second frame. That value is only in the
     GIF because of when the GIF was cut, and the button is now the one CTA in
     the bar selling the current issue, so matching the issue won out. Kept as
     its own token rather than using --issue-accent at the call sites: what the
     button wants is "the issue's pink", and a future issue could want to break
     that tie without dragging the featured section with it. The GIF is still
     the reference for the pill's shape and cadence, just not its colour. */
  --color-buy-flash: var(--issue-accent);

  /* The current issue's accent — the one hex to change when the issue rolls
     over. Everything in the featured section derives from it: the screen wash
     over each photograph and the CTA's hover fill, at both breakpoints. Set
     here so it is a single edit; an inline `style="--issue-accent: #hex"` on
     the .features section overrides it locally, which is the quick way to
     preview a candidate colour.

     Two things consume this: the featured section, and --color-buy-flash above,
     which the nav Buy now pill flashes. archive.html keeps a per-row
     --issue-wash for past issues and is unaffected. Extending it to a third
     section should be a decision, not an accident. */
  --issue-accent: #ee3288;

  /* ── The site palette ── */
  /* Fourteen colours, supplied 2026-08-26 as *the* palette. They are the same
     fourteen `--issue-wash` values archive.html sets inline, one per back issue,
     in that order — several also drive the lit-scene module washes on
     index.html. Tokenised here because the desktop menu's hover flash needs to
     pick one at random, and JS reads them off :root by name: it walks
     --color-palette-1 upward until a lookup comes back empty, so adding a
     fifteenth colour is an edit to this block alone.

     The footer gravity icons are NOT this palette and are the documented
     exception — their six colours are baked into the twenty SVG files, so they
     cannot be driven from here and are not listed here. Do not try to reconcile
     the two sets.

     The duplication with archive.html is deliberate for now: those inline values
     mean "issue 11's colour", not "palette slot 4", and the two only coincide
     today. If they should be one source of truth, that is a separate decision. */
  --color-palette-1: #39a8f6;
  --color-palette-2: #7eee4f;
  --color-palette-3: #ff574b;
  --color-palette-4: #fb47da;
  --color-palette-5: #fff235;
  --color-palette-6: #f90061;
  --color-palette-7: #0093f3;
  --color-palette-8: #00d900;
  --color-palette-9: #ff4225;
  --color-palette-10: #c150cd;
  --color-palette-11: #e5ce00;
  --color-palette-12: #e12672;
  --color-palette-13: #00c9dd;
  --color-palette-14: #00be29;

  /* Century Schoolbook Pro sets a conspicuously wide word space: 0.335em
     against 0.250 for Times and Caslon Ionic and 0.241 for Georgia, so about a
     third wider than a normal text serif. At display sizes that reads as gaps
     rather than spacing. This pulls it back to ~0.275em — still looser than
     Times, which suits the face, without the holes.

     Applied wherever --font-serif is, rather than inherited from body, because
     body is the sans and Arial Narrow is a condensed face that would look
     cramped with the same correction. The two tokens travel together: if you
     set --font-serif on a new rule, set this on it too.

     One caveat: it is a flat em adjustment, so during the font-display: swap
     window — or if the woff2 never arrives — it also applies to the Georgia
     fallback, whose space is already narrower. Brief and rare enough to accept
     rather than chase with a JS font-loading class. */
  --word-space-serif: -0.06em;

  /* Width of the right-hand copy column's content. Both two-column regions are
     built on it — .spread (issue body, contributors, featured, buy bar) and the
     lit scene's module column — and both express their tracks in `fr`, so the
     track is proportional and only *approaches* this value. This caps it so the
     content never runs past the designed measure.

     Worth knowing before adjusting: the two regions disagree slightly in the
     source. .spread is 759 + 53 gutter + 1089, summing to 1901; the lit scene is
     748 + 54 + 1088, summing to 1890. So the padding that makes each land exactly
     on 1088 differs — about 10 a side for .spread, 15 for the lit scene — and
     neither is the 20 in use. At 1920 that leaves the column around 1077 rather
     than 1088, an 11px shortfall. Closing it means taking the difference out of
     the cover column or the side padding. */
  --copy-max: 1088px;

  /* Families. Georgia leads the serif fallback because its proportions
     sit closest to Century Schoolbook. Arial Narrow ships on macOS and
     Windows but not on Android or most Linux, so the sans stack falls
     back through other condensed faces before plain Arial. */
  --font-serif:
    "Century Schoolbook Pro", "Century Schoolbook", "Century Schoolbook Fallback", Georgia,
    "Times New Roman", serif;
  --font-sans:
    "Arial Narrow", "Helvetica Neue Condensed", "Liberation Sans Narrow", "Nimbus Sans Narrow",
    Arial, sans-serif;
  /* Caslon Ionic is back, and only the archive page uses it: desktop sets the
     issue number and the price in it (1271:16787, 1271:16794) where mobile sets
     the same two in Century Schoolbook. It had been dropped when the updated
     mobile frame removed the hero cover's Buy now label, which was its only
     other appearance. No files are in hand, so this resolves to the same
     Georgia-based fallback as --font-serif and is visually identical for now —
     the token exists so the swap is one line once the face lands. */
  /* Real files as of 2026-08-24; the rest of the stack is now only a net for a
     failed load. */
  --font-caslon: "Caslon Ionic", "Century Schoolbook Fallback", Georgia, "Times New Roman", serif;

  /* Type scale — values taken verbatim from the Figma MW/MOBILE variables.
     Every one of these turned out identical at the MW/DESKTOP equivalent
     too, confirmed as each was put to use: --type-heading/-body by
     contact.html, --type-lead/-arial and --type-date by
     privacy-policy.html, --type-caption by the footer legal links. */
  --type-heading-size: 28px;
  --type-heading-lh: 1.1;
  --type-body-size: 26px;
  --type-body-lh: 1;
  --type-lead-size: 24px;
  --type-lead-lh: 1.1;
  --type-arial-size: 18px;
  --type-arial-lh: 1.1;
  --type-caption-size: 12px;
  --type-caption-lh: 1.1;
  /* "Last Updated" captions on the legal pages — Century Schoolbook, not
     Arial like --type-caption, and its own muted colour rather than body
     text. First used by privacy-policy.html, 2026-08-28. */
  --type-date-size: 11px;
  --type-date-lh: 1.4;

  /* Layout */
  --content-max: 1200px;
  /* 15px, not the 20px the scaffold guessed: the mobile frame is 389 wide
     with a 360.4 content column (hero title, subscribe, issue body all
     measure ~360), which leaves ~14.3 a side. */
  --pad-side: 15px;
  /* Desktop is 60px (1124:15208). Mobile is a measured number, not 779:1804's
     107 — that frame draws a two-row bar and the second row, the scrolling
     contributor names, was cut 2026-08-26. It went 107 → 76 with that cut, then
     76 → 55 the same day when the buy button's height was reverted: 55 was
     15 top padding + 24.35 row + 15 bottom padding, rounded up so a hair of slack
     remained rather than overflowing the content box by a fraction and riding up,
     which is the trap the desktop align-content note describes.
     55 is kept, but it is no longer that tight sum: the 15% size reduction later
     the same day dropped the row to 21.49, so the bar now carries 3.5 of slack,
     which align-content: end puts above the row — 18.5 over it against the
     declared 15 under. That reads as breathing room rather than the lopsided
     dead air 76 had, and it was left alone deliberately; 52 is the snug value if
     the bar should hug the row again.
     The row is as tall as the buy button, so this tracks that button. Re-measure
     it — and this — if its padding or font size moves, or if the logo, the issue
     label or the toggle glyph grows past it. */
  --navbar-h: 63px;
  /* Anchor offset. Zero on mobile because the nav sits at the bottom, so
     nothing overlaps a section's top edge. */
  --scroll-offset: 0px;

  /* Menu slide. UNCONFIRMED: the open transition is a Figma prototype
     interaction, and get_motion_context returns {"nodes":[]} for both
     779:1854 and 1124:15223 — the data is not exposed. Direction is taken
     from the layout (the panel emerges from behind whichever bar holds
     the toggle), and these two values are a house guess. Needs a screen
     recording to confirm, the way the buy bar's hover did. */
  --menu-slide: 420ms;
  --menu-ease: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* Nav Buy now flash. Cadence read out of button-vol0.gif, which alternates
     two full-frame states at 1.50s each — a 3s cycle, 0.33Hz, far under the
     3Hz WCAG 2.3.1 ceiling. The GIF's own Netscape loop count is 1, so the
     supplied asset flashed once and stopped; looping forever is a deliberate
     departure from it, confirmed 2026-08-24. Keep it well under 3Hz if the
     cadence is ever retuned — an infinite flash is the case 2.3.1 is about. */
  --buy-flash-cycle: 3s;
}

@media (min-width: 680px) {
  :root {
    /* 20, not the 48 this used to be. The desktop artboard runs nearly edge to
       edge — 1849 wordmark, 1892 nav bar on a 1920 frame — so 48 a side was
       squeezing the two-column region well inside its designed width. */
    --pad-side: 20px;
  }
}

/* The desktop frame is a 1920 artboard whose content runs nearly edge to
   edge — the wordmark is 1849 wide, the nav bar 1892 — so the 1200 cap
   used below the breakpoint would squeeze the whole layout. */
@media (min-width: 960px) {
  :root {
    --content-max: 1920px;
    --navbar-h: 60px;
    /* Desktop bar is sticky at the top, so anchors must clear it */
    --scroll-offset: 60px;
  }
}

/* ── Page Shell ── */
html {
  scroll-behavior: smooth;
}

/* Backstop against a horizontal swipe navigating the page. The carousels that
   need it already contain their own overscroll — .parties__track and
   .contributors__row — and this catches anything that gets past them, or any
   scroller added later that forgets to.

   Only the x axis: the shorthand would also contain vertical overscroll and
   take pull-to-refresh with it, which is not ours to disable.
   
   Worth knowing what this cannot do: it governs overscroll, so it stops the
   Chrome/Android back gesture and the desktop two-finger swipe. iOS Safari's
   back is an edge-of-screen gesture rather than overscroll, and Apple exposes
   no way to turn it off from CSS or JS — so on iOS a swipe begun within a few
   points of the left bezel can still navigate, and the only real mitigation is
   not running swipeable content to the bezel. The launch parties row is
   deliberately full bleed, so that trade is already made. */
html,
body {
  overscroll-behavior-x: contain;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Clears the fixed bottom bar so the end of the page is not hidden
   behind it. Desktop's bar is sticky at the top, so no offset needed. */
body {
  padding-bottom: var(--navbar-h);
}

@media (min-width: 960px) {
  body {
    padding-bottom: 0;
  }
}

body.nav-open {
  overflow: hidden;
}

.section {
  width: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 64px var(--pad-side);
  /* Offset so anchor links don't land under the sticky header */
  scroll-margin-top: var(--scroll-offset);
}

@media (min-width: 960px) {
  .section {
    padding: 96px var(--pad-side);
  }
}

/* Skip link — keyboard accessibility */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 12px 16px;
  background: var(--color-text);
  color: var(--color-bg);
}

.skip-link:focus {
  left: 0;
}

/* Available to screen readers, removed from the visual layout — used for
   form labels the design shows only as placeholder text. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── Typography ── */
/* One class per Figma text style, so section markup names the design
   token instead of restating sizes. Mobile values; desktop overrides land
   with the MW/DESKTOP tokens. */
.t-heading {
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: var(--type-heading-size);
  line-height: var(--type-heading-lh);
  font-weight: 400;
}

.t-body {
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: var(--type-body-size);
  line-height: var(--type-body-lh);
  font-weight: 400;
}

.t-lead {
  font-family: var(--font-sans);
  font-size: var(--type-lead-size);
  line-height: var(--type-lead-lh);
  font-weight: 400;
}

.t-arial {
  font-family: var(--font-sans);
  font-size: var(--type-arial-size);
  line-height: var(--type-arial-lh);
  font-weight: 400;
}

.t-caption {
  font-family: var(--font-sans);
  font-size: var(--type-caption-size);
  line-height: var(--type-caption-lh);
  font-weight: 400;
}

.t-date {
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: var(--type-date-size);
  line-height: var(--type-date-lh);
  font-weight: 400;
  color: var(--color-muted);
}

/* ── Nav bar ── */
/* Desktop 1124:15208 — 60px tall, sticky to the top, rule below.
   Mobile  779:1804  — 107px tall, fixed to the BOTTOM, rule above.
   Opposite edges at the two breakpoints, so this is not one bar restyled
   but two arrangements of the same content. */
.navbar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 70;
  height: var(--navbar-h);
  background: var(--color-bg);
  border-top: 1px solid var(--color-text);
}

.navbar__inner {
  display: grid;
  /* One row on mobile: brand + toggle. The credits row was cut 2026-08-26 and
     the area goes with it — leaving "credits credits" declared would keep a
     second track alive, and grid paints the 12px row gap between tracks whether
     or not anything occupies them, so an empty row still costs 12px. */
  grid-template-areas: "brand toggle";
  grid-template-columns: 1fr auto;
  align-items: center;
  /* Pack the rows to the *bottom* of the content box. Two reasons, both about
     the bar being a fixed --navbar-h that is taller than its contents.

     First, the rows must not stretch: with the default the two auto rows split
     the spare height and each centred its own contents, which silently widened
     the row gap — 7.7px of it even at gap: 0.

     Second, `end` is what makes the bottom padding literal. Packed to the top
     the spare height piled up *below* the second row, so a declared 40 read as
     55.4 on screen. Packed to the bottom the second row sits exactly 40 off the
     bar's bottom edge and the spare moves above the first row instead, which is
     why the top gap measures ~19 rather than the declared 14.

     Harmless on desktop, whose single row fills its content box exactly. */
  align-content: end;
  /* Only the column gap does anything on mobile now that there is a single row;
     the 12px row value is kept as the value to restore the credits row with, and
     is inert until then. Desktop replaces the whole shorthand with 27. */
  gap: 12px 24px;
  height: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  /* Symmetric 15 as of 2026-08-26. It was 14 top / 40 bottom, which is what the
     two-row frame draws; with one row that 40 read as dead space under the
     content. 15 a side against the 24.35 row is what --navbar-h's 55 is built
     from, so the bar is full to within 0.65 (which align-content: end puts on
     top). Desktop replaces the whole shorthand with 15px 10px. */
  padding: 17.25px var(--pad-side);
}

.navbar__brand {
  grid-area: brand;
  display: flex;
  align-items: center;
  /* The gap is DISTRIBUTED, not declared, as of 2026-08-26. This box is the
     grid's `1fr` so it stretches to the full track, and space-between hands every
     pixel the elements do not use back to the two gaps between them — so they are
     as wide as each viewport can afford, with no number to re-tune. Measured: 12.6
     at 375, 20.1 at 390, 32.1 at 414, then held at the ceiling below.
     This replaced a fixed gap that had been 18 (the frame, 779:1808), then 10,
     then 24, each time because a size change had eaten or freed width. The row is
     width-critical — see the note on .navbar__logo img — and a fixed gap is the
     wrong lever on a row that cannot reflow.
     The 8px is a floor for the narrowest screens, where there is less free space
     than that to distribute; it only binds below ~330. Desktop overrides both.

     The max-width is the other end of the same idea — a ceiling on the spread.
     Without it this box keeps stretching to the whole `1fr` track, and since the
     mobile bar runs to 959 that meant 300px gaps on a tablet, with the wordmark
     and the CTA at opposite ends of the screen. 324 is the elements (258.8) plus
     two 32.6 gaps, so the gaps open to about 32 and stop; past that the surplus
     goes to the toggle side and the brand stays a group. Desktop must clear this
     too — its brand is 338.7 wide and would otherwise be clamped. */
  justify-content: space-between;
  gap: 8px;
  max-width: 324px;
  min-width: 0;
}

.navbar__logo {
  display: block;
  flex-shrink: 0;
}

/* 87. The frame is 84 (779:1809 — 84.205 x 11.509); this went to 102 on
   2026-08-26 when the bar was scaled up, then back 15% to 87 the same day, with
   every other element in the row, so the gaps between them could grow.

   THE MOBILE ROW IS WIDTH-CRITICAL. Everything in it sets `flex-shrink: 0` and
   `white-space: nowrap`, so there is no give: at a 375 viewport the content box
   is 345px and the row needs 320.8 of it. That is 24.2 of slack, up from single
   digits before the 15% reduction — but the reduction spent most of what it
   freed on the 24px brand gaps, so the margin is comfortable rather than large.
   Growing the logo, the issue label, the buy button's *horizontal* padding, the
   toggle or those gaps any further eats it and then overflows the bar rather
   than reflowing it.
   The button's vertical padding is the one dimension that costs no width, so it
   was briefly used to fill the bar; that was reverted on request and --navbar-h
   now follows the row's height instead. Re-measure at 375 after touching any of
   these. */
.navbar__logo img {
  display: block;
  width: 100px;
  height: auto;
}

.navbar__issue {
  flex-shrink: 0;
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-style: italic;
  /* 13.6 — the frame's 14 (779:1812) went to 16 with the scale-up and then back
     15% with the rest of the row, landing just under it. */
  font-size: 15.6px;
  line-height: 1.1;
  white-space: nowrap;
}

.navbar__buy {
  display: block;
  flex-shrink: 0;
  color: inherit;
  text-decoration: none;
}

/* 1448:18558 — Figma emits a sub-pixel border width here, which renders
   inconsistently across browsers, so this uses a hairline 1px instead. */
.navbar__buy-label {
  /* --buy-rest is the half of the flash that is not the accent; desktop
     overrides it to black. Declared here so one @keyframes serves both. */
  --buy-rest: var(--color-bg);
  display: inline-block;
  /* The frame's vertical padding, kept. It was briefly 12px top and bottom, to
     stand the button at 45.4 and fill the bar with the one dimension in this row
     that costs no width; that read as far too big for the button and was
     reverted on request 2026-08-26, and --navbar-h came down to suit the shorter
     row instead. The horizontal 7.5 is the frame's 3 opened up for the larger
     type, then cut 15% with the rest of the row; unlike the vertical it costs
     width, so it counts against the budget in .navbar__logo img's note. */
  padding: 1.38px 8.6px 2.07px;
  background: var(--buy-rest);
  border: 1px solid var(--color-text);
  font-family: var(--font-sans);
  /* 15 — 14.71 went to 17.6 with the scale-up, then back 15% with the rest of
     the row. It is the tallest thing in the row, so it sets the row height and
     therefore --navbar-h; see that token. */
  font-size: 17.25px;
  line-height: 1.1;
  text-align: center;
  text-transform: uppercase;
  white-space: nowrap;
  animation: buy-flash var(--buy-flash-cycle) infinite;
}

/* A hard cut, not a fade: the GIF this replaces swapped whole frames. Text
   and border hold still — only the fill moves, so black type sits on the
   accent the way the mobile buy star does. */
@keyframes buy-flash {
  0%,
  49.99% {
    background: var(--buy-rest);
  }
  50%,
  100% {
    background: var(--color-buy-flash);
  }
}

/* The flash is decorative and it is the only thing moving in the bar. Rest on
   the magenta rather than the neutral, so the colour is still present. */
@media (prefers-reduced-motion: reduce) {
  .navbar__buy-label {
    animation: none;
    background: var(--color-buy-flash);
  }
}

/* Desktop only as of 2026-08-26, on request — the scrolling contributor names
   are cut from the mobile bar and the bar lost a row with them (see
   --navbar-h). The markup stays for desktop, and `display: none` means mobile
   generates no box for it, so the 45s marquee animation does not run there
   either. Restoring it on mobile takes three edits together: this rule back to
   `flex`, the credits row back into .navbar__inner's grid-template-areas, and
   --navbar-h back to 107. */
.navbar__credits {
  grid-area: credits;
  display: none;
  align-items: center;
  gap: 9px; /* 1124:15216 gap */
  min-width: 0;
}

.navbar__credits-label {
  flex-shrink: 0;
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: 14px; /* 779:1818 */
  line-height: 1.1;
  white-space: nowrap;
}

/* Marquee. The Figma original is a video; this reproduces the effect in
   CSS over two identical tracks, each translating by its own width, so
   the second lands exactly where the first began and the loop is seamless
   rather than gapping. */
.navbar__marquee {
  flex: 1 1 auto;
  display: flex;
  min-width: 0;
  overflow: hidden;
}

.navbar__marquee-track {
  display: flex;
  flex-shrink: 0;
  gap: 0;
  list-style: none;
  white-space: nowrap;
  will-change: transform;
  animation: marquee 45s linear infinite;
}

.navbar__marquee-track li {
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-style: italic;
  font-size: 14px;
  line-height: 1.1;
}

/* Comma separators in CSS so the markup stays a clean list */
.navbar__marquee-track li::after {
  content: ",\00a0";
}

@keyframes marquee {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-100%);
  }
}

/* Motion is decorative here — hold it still and let the names wrap out of
   view rather than scroll. */
@media (prefers-reduced-motion: reduce) {
  .navbar__marquee-track {
    animation: none;
  }
}

/* The box hugs the glyph — it used to be a 44x44 square, which put dead space
   above and below the icon and, being the tallest thing in the bar's first row,
   set that row's height. The 44 was there for the touch target, so that moves
   to ::after below rather than being dropped.

   32.3x13.2 on mobile as of 2026-08-26 — up from the frame's 27x11 when the bar
   was scaled up, then back 15% with the rest of the row. Desktop is overridden
   back to 27x11 — it was reading the base rule, so it needs an explicit value
   now. 32.3 is still inside the 44 the touch target draws, so ::after keeps
   governing the hit area at both sizes; take the glyph past 44 and that stops
   being true. */
.navbar__toggle {
  position: relative;
  grid-area: toggle;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 37.1px;
  height: 15.2px;
  padding: 0;
  background: none;
  border: 0;
  cursor: pointer;
}

/* The touch target the box no longer provides: 44x44 centred on the glyph,
   absolute so it costs nothing in layout and overhangs the button instead.
   Keep it — 27x11 on its own is a 297px² target against the 44x44 that WCAG
   2.5.5 asks for, and it sits in the thumb zone at the bottom of the screen. */
.navbar__toggle::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.navbar__toggle img {
  display: block;
  width: 37.1px;
  height: auto;
}

/* Hamburger swaps to a CLOSE X label while the menu is open */
.navbar__toggle-close {
  display: none;
  font-family: var(--font-sans);
  font-size: var(--type-caption-size);
  line-height: var(--type-caption-lh);
  text-transform: uppercase;
  white-space: nowrap;
}

.navbar__toggle[aria-expanded="true"] .navbar__toggle-icon {
  display: none;
}

.navbar__toggle[aria-expanded="true"] .navbar__toggle-close {
  display: block;
}

/* Open state swaps the glyph for a CLOSE X label, so the glyph-sized box has
   to give way or the text is clipped. */
.navbar__toggle[aria-expanded="true"] {
  width: auto;
  height: auto;
}

/* Narrow-phone floor for the scaled-up bar. NOT a general breakpoint — the
   site's only one is 960 (see CLAUDE.md); this is a guard on one row that cannot
   reflow, and nothing outside .navbar belongs in it.

   Why it is needed: every item in the mobile row is `flex-shrink: 0` and
   `nowrap`, and .navbar__brand is the grid's `1fr` with `min-width: 0`, so when
   space runs out the brand box shrinks and its contents overflow *underneath the
   toggle* instead of wrapping. The document does not scroll and nothing looks
   broken in a screenshot — the BUY NOW button simply lands on top of the menu
   button. Measured, the row puts the button's right edge at a fixed 288.5 while
   the toggle's 44px hit area starts at viewport - 53.15, so they collide below
   ~342px. 360 (the common Android width) clears it by 18.5px; 320 would overlap
   the hit area by 21.7.

   The threshold is 375 rather than 342 on purpose, and 360 is the reason. That
   18.5px of clearance assumes the button's text measures what it does here, and
   the button is `var(--font-sans)` — Arial Narrow, which per the blockers is
   absent on Android, exactly the platform 360 describes. The stack falls through
   to whatever condensed face that device has, which may well set *wider*: 18.5 is
   20% of this button, and a substitute can easily be that much wider. So 360
   takes the smaller type and keeps 60.8px of clearance instead.

   Below 375, then, every value in the row takes another 15% off — the same cut
   the whole bar took, applied again — which is what keeps the 24px gaps fitting.
   Checked at 320: row needs 275 of 290, button clear of the hit area by 20.8.
   Re-measure both numbers if any width in the row changes. */
@media (max-width: 374.98px) {
  .navbar__logo img {
    width: 82.1px;
  }

  .navbar__issue {
    font-size: 13.7px;
  }

  .navbar__buy-label {
    padding: 1.38px 5.75px 2.07px;
    font-size: 14.4px;
  }

  .navbar__toggle {
    width: 29.3px;
    height: 12px;
  }

  .navbar__toggle img {
    width: 29.3px;
  }
}

/* Desktop 1124:15208 — one row, pinned to the top instead of the bottom */
@media (min-width: 960px) {
  .navbar {
    position: sticky;
    top: 0;
    bottom: auto;
    border-top: 0;
    border-bottom: 1px solid var(--color-text);
  }

  .navbar__inner {
    grid-template-areas: "brand credits toggle";
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: 27px; /* 1124:15210 gap */
    padding: 15px 10px; /* 1124:15209 offset */
    /* Undo the mobile align-content: end. It is not neutral here even though
       there is only one row: this bar is 60 tall *including* its 1px border, so
       15 + 30 + 15 overflows the content box by exactly that pixel, and packing
       to the end spends it upward — every item lands 1px high. */
    align-content: normal;
  }

  /* Desktop keeps the marquee — mobile hides it. Unchanged from the build. */
  .navbar__credits {
    display: flex;
  }

  .navbar__logo img {
    width: 126px; /* 1124:15211 — 126.308 x 17.264 */
  }

  .navbar__issue,
  .navbar__credits-label,
  .navbar__marquee-track li {
    font-size: 21px; /* 1124:15214 / 15217 */
  }

  /* Desktop draws a filled pill (1271:16971), where mobile draws a bordered
     text button. This was button-vol0.gif until the flash had to follow the
     issue accent; see the note on the markup. Geometry is the GIF's own —
     90x30 (1124:15215), from a 123x41 raster whose black fills the frame
     edge to edge. The 17.6px is derived, not read off a node: the white type
     measures 100x23 in that raster, and 23px of cap-to-descender over
     Century Schoolbook's ~0.93em, scaled by 90/123, lands there. */
  .navbar__buy-label {
    --buy-rest: var(--color-text);
    display: flex;
    align-items: center;
    justify-content: center;
    width: 90px;
    height: 30px;
    padding: 0;
    border: 0;
    color: var(--color-bg);
    font-family: var(--font-serif);
    word-spacing: var(--word-space-serif);
    font-size: 17.6px;
    text-transform: none;
  }

  /* The GIF carries no arrow, so the pill does not either. */
  .navbar__buy-arrow {
    display: none;
  }

  /* Desktop declares its gap rather than distributing one, so both halves of
     the mobile rule have to be undone. The justify-content reset is belt and
     braces — this brand sits in an `auto` track, so it hugs its contents and
     space-between would have nothing to distribute — but the track is auto by a
     hair and sub-pixel rounding should not be what keeps the frame's 27 intact. */
  .navbar__brand {
    justify-content: flex-start;
    /* Clears the mobile spread ceiling. This brand measures 338.7, so the
       mobile 324 would clamp it and squeeze the frame's 27px gaps. */
    max-width: none;
    gap: 27px; /* 1124:15210-ish; the frame's brand gap */
  }

  /* Holds the frame's 27x11 (1124:15212). Desktop used to inherit this from the
     base rule; the base is 38x15.5 since the mobile bar was scaled up on
     2026-08-26, so the value has to be stated here to keep desktop unchanged. */
  .navbar__toggle {
    width: 27px;
    height: 11px;
  }

  .navbar__toggle img {
    width: 27px;
  }
}

/* ── Masthead ── */
/* Mobile 1205:15800 (360×169), desktop 1271:16962 (1849×253) — the shared
   VOLUME 0 wordmark that opens the home hero and every other page's
   masthead. Width-constrained with height auto so each asset keeps its
   own ratio.

   `.hero__wordmark` moved here 2026-08-28: index.html carried these rules
   in its own inline <style>, so archive.html's `.hero__wordmark` — same
   class, same markup — had no CSS at all and rendered the SVG at its raw
   intrinsic size instead of this constrained one, which is why it read
   larger there than on the home page.

   `.masthead` joined it the same day, when contact.html became the second
   page (after archive.html) to need the wrapper that centres the wordmark
   above a section and clears the sticky desktop nav — with a third
   (privacy-policy.html / terms-of-service.html) already planned. Same
   reasoning as the `.hero__wordmark` move: once two pages want a rule it
   belongs here, not duplicated inline. index.html doesn't use `.masthead`
   — its wordmark sits inside `.hero` instead, which also carries the star
   and issue title, so it keeps its own wrapper. */
.hero__wordmark {
  display: block;
  width: 100%;
  max-width: 360px;
  padding: 0 var(--pad-side);
}

.hero__wordmark img {
  display: block;
  width: 100%;
  height: auto;
}

.masthead {
  display: flex;
  justify-content: center;
  padding: 64px 0 0; /* mobile wordmark sits at y 70 in a 389 column */
}

/* The star-plus-heading that opens a page's main content, below the
   masthead — contact.html and privacy-policy.html draw it identically
   (16px star, 8px gap, 28px Century Schoolbook uppercase title), so it
   moved here 2026-08-28 rather than duplicating a third time on
   terms-of-service.html. archive.html's own version is NOT this class:
   its star-to-title gap measures 10px, a real difference from these two
   pages' 8px, so it keeps its own `.archive__head` rather than being
   folded in and either changing its spacing or forcing an override. */
.page-head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

.page-head__star {
  display: block;
  width: 16px;
  height: 16px;
}

.page-head__title {
  text-transform: uppercase;
}

@media (min-width: 960px) {
  .hero__wordmark {
    max-width: 1849px;
  }

  .masthead {
    padding-top: 21px; /* frame top (81) minus the 60px nav */
  }
}

/* ── Legal ── */
/* The long-form document layout — privacy-policy.html and
   terms-of-service.html, both added 2026-08-28, measure identically down
   to the pixel (32/60 gap and padding-top mobile, 60/100 desktop, 1100px
   body column), so this moved here rather than being copy-pasted a
   second time. Figma applies one uniform gap between every child in this
   column — masthead, the heading group, and the body copy — 32 mobile,
   60 desktop. The masthead sits outside this section (`.masthead`
   above), so that first gap becomes this section's own padding-top; the
   second is the flex gap below. Bottom padding is the separate measured
   gap down to the footer: 60 mobile, 100 desktop. */
.legal {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;
  padding-top: 32px;
  padding-bottom: 60px;
}

/* Wraps .page-head plus the "Last Updated" / "Effective" line below it.
   Its own gap (title-group to date) is 8 mobile, matching .page-head's
   own star-to-title gap exactly — so mobile's three lines read as one
   evenly-spaced column — but 12 on desktop, where the two gaps diverge.
   That's why this is a second, outer flex column rather than folding the
   date into .page-head itself. */
.legal__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

/* The body column: dozens of paragraphs, headings and (on privacy-policy
   only) one list, all in one flex column. Applying .t-lead / .t-arial to
   every single node would mean repeating a class on several dozen tags
   for pages that are otherwise uniform prose, so the same tokens are
   referenced once here instead, by element — headings get
   --type-lead-*, everything else gets --type-arial-*. It is the class
   attribute being skipped, not the token, so the two never drift apart
   if either token's value changes. */
.legal__body {
  display: flex;
  flex-direction: column;
  gap: 24px;
  width: 100%;
}

.legal__body h2,
.legal__body h3 {
  font-family: var(--font-sans);
  font-size: var(--type-lead-size);
  line-height: var(--type-lead-lh);
  font-weight: 400;
}

.legal__body p,
.legal__body li {
  font-family: var(--font-sans);
  font-size: var(--type-arial-size);
  line-height: var(--type-arial-lh);
  font-weight: 400;
}

/* Figma types each bullet as "•   " (a bullet plus three hand-typed
   spaces), not a real list — rebuilt as a semantic <ul> instead, since a
   screen reader announcing "list, 11 items" is worth more than matching
   a design tool's lack of list semantics. list-style is the browser's
   own disc rather than a pixel-matched custom marker, which isn't worth
   the effort on a utility page like this. Only privacy-policy.html uses
   this so far — terms-of-service.html has no list. */
.legal__list {
  display: flex;
  flex-direction: column;
  gap: 12px;
  list-style: disc outside;
  padding-left: 1.2em;
}

/* Figma shows every inline link as plain black text — no underline, no
   colour change — matching contact.html's emails. */
.legal__body a {
  color: inherit;
  text-decoration: none;
}

@media (min-width: 960px) {
  .legal {
    gap: 60px;
    padding-top: 60px;
    padding-bottom: 100px;
  }

  .legal__head {
    gap: 12px;
  }

  .legal__body {
    gap: 36px;
    /* 1100px, centred — measured off both frames (x 410 in a 1920 frame,
       (1920-1100)/2 = 410, confirmed on privacy-policy.html and
       terms-of-service.html alike). A narrower column than the page's
       own --content-max, for the same reason a book sets a narrower
       measure than its page: 1100px of Arial Narrow at 18px is a
       readable line length for continuous prose in a way the full
       content width would not be. */
    max-width: 1100px;
  }

  .legal__list {
    gap: 10px;
  }
}

/* ── Menu (nav overlay) ── */
/* Mobile 779:1854, desktop 1124:15223. Full-screen overlay with two
   labelled columns of links and a CLOSE X. */
.menu {
  position: fixed;
  inset: 0;
  /* Above the nav bar (70). On mobile that is what lets the overlay cover
     the bottom bar, as the design shows — CLOSE X at the foot of the
     overlay replaces it. On desktop there is no conflict regardless,
     because the overlay starts below the bar rather than over it. */
  z-index: 80;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 32px;
  /* No padding around the art at all: the spiral bleeds to the top edge
     as well as both sides (its node starts at y -13 once the container
     offset is applied). The link block and close button carry their own
     inset instead. */
  padding: 0 0 24px;
  background: var(--color-bg);
  overflow-y: auto;
  /* Slides up from behind the bottom bar, which is where the toggle
     lives. See --menu-slide for why the timing is a guess. */
  transform: translateY(100%);
  transition: transform var(--menu-slide) var(--menu-ease);
}

.menu[data-open="true"] {
  transform: translateY(0);
}

/* JS holds `hidden` off for the length of the transition on close, so the
   panel is still painted while it slides out. */
.menu[hidden] {
  display: none;
}

/* No slide at all under reduced motion — it snaps, as before. */
@media (prefers-reduced-motion: reduce) {
  .menu {
    transition: none;
  }
}

/* 1124:15482 — the spiral panel with the cover floating on it.
   Mobile is 326x365 inside a 390 frame (779:1854). The Figma spiral node
   bleeds past the panel edges; reproduced here as object-fit: cover on a
   full-size image, which is visually equivalent and far simpler than
   replicating the nested offsets. */
/* Full-bleed: the spiral child is 412 wide at x -38 inside a 408 frame,
   so it deliberately runs past both screen edges. Its own 412x393 box —
   not the 326x365 container — sets the visible area, and the cover and
   button are positioned against that. */
.menu__art {
  position: relative;
  width: 100%;
  max-width: none;
  aspect-ratio: 412 / 393;
  margin: 0;
  overflow: hidden;
  flex-shrink: 0;
}

.menu__art-spiral {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 1124:15484/15485 — aspect 804:1272. Offsets are relative to the spiral
   box: the cover centre sits at 187 of 393 vertically. */
.menu__art-cover {
  position: absolute;
  left: 50%;
  top: 47.6%; /* 187 / 393 */
  width: 29.1%; /* 118.854 / 408 */
  transform: translate(-50%, -50%);
  aspect-ratio: 804 / 1272;
  box-shadow: 0 3px 26px rgba(0, 0, 0, 0.35);
}

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

/* 1124:15486 — also relative to the spiral box */
.menu__art-buy {
  position: absolute;
  left: 40.8%; /* 168 / 412 */
  top: 77.6%; /* 305 / 393 */
  padding: 1.5px 3.6px 2.2px;
  background: var(--color-bg);
  border: 0.728px solid var(--color-text);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: 17.48px;
  line-height: 1.1;
  text-align: center;
  text-decoration: none;
  text-transform: uppercase;
  white-space: nowrap;
}

.menu__inner {
  display: flex;
  flex-direction: column;
  gap: 43px; /* 779:1851 gap */
  margin-top: auto;
  margin-bottom: auto;
  padding: 0 var(--pad-side);
}

.menu__group {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px; /* 779:1850 gap */
}

.menu__label {
  font-family: var(--font-sans);
  font-size: var(--type-caption-size); /* 779:1835 — 12px */
  line-height: var(--type-caption-lh);
  font-weight: 400;
}

.menu__list {
  display: flex;
  flex-direction: column;
  gap: 4px; /* 779:1849 gap */
  width: 158px; /* 779:1849 width */
  list-style: none;
  /* 779:1851 sets text-center, which the links inherit */
  text-align: center;
}

.menu__list a {
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: var(--type-body-size); /* 26px */
  line-height: 1;
  color: inherit;
  text-decoration: none;
}

/* Hover flash on the desktop menu links, per the annotation on 1271:17061:
   "these should hover and color change randomly based on the colors we have in
   our site's palette". Random is the designer's word, so the colour is not
   assigned per link — site.js picks one off :root on every pointerenter and
   writes it to --menu-flash on that link, which is the only thing this rule
   reads. Desktop only, as requested; the mobile menu is untouched.

   A hard cut, no transition. That matches the one other flash in this build,
   the nav Buy now pill, which swaps whole frames because the GIF it replaces
   did. Add a transition here and it stops reading as a flash.

   --color-palette-1 is the no-JS fallback, so a browser with scripting off
   still gets hover feedback — one fixed blue instead of a random colour, which
   is a graceful loss rather than a dead state.

   `(hover: hover)` is a capability query, not a new breakpoint. Without it a
   touch device wide enough to be "desktop" latches :hover on tap and the colour
   sticks until you tap elsewhere.

   NOT applied to :focus-visible on purpose. Eight of these fourteen colours
   fail 3:1 against the white panel — see the contrast blocker — and a keyboard
   user should not be forced into a low-contrast state to know where focus is.
   Hover is a pointer flourish the user opted into by pointing at it. */
@media (min-width: 960px) and (hover: hover) {
  .menu__list a:hover {
    color: var(--menu-flash, var(--color-palette-1));
  }
}

/* The mobile order swap that used to sit here is gone with the links it
   moved. The two frames disagree on order — desktop (1124:15223) lists About
   before Parties, mobile (779:1854) the reverse — so a band of nth-child
   `order` rules swapped that pair below the breakpoint. About and Parties were
   both cut on 2026-08-26, leaving Shop and Archive, which the frames agree on
   and which are already in DOM order. Restoring either link means restoring
   the swap too, and the question of which frame is right was never settled —
   it is more likely a design inconsistency than a decision. */

/* 779:1853 */
.menu__close {
  align-self: center;
  padding: 12px 24px;
  background: none;
  border: 0;
  color: inherit;
  font-family: var(--font-sans);
  font-size: var(--type-caption-size);
  line-height: var(--type-caption-lh);
  text-transform: uppercase;
  cursor: pointer;
}

/* Desktop 1124:15223 — links sit right of centre, larger, left-aligned.
   Column position measured off the state render rather than node
   metadata (the node is a full page the MCP server cannot return), so
   these are close-fit rather than exact: labels land at ~59% of the
   viewport and links at ~64%, matching the render. */
@media (min-width: 960px) {
  /* Absolute children rather than a grid: every position in this state is
     a percentage of the viewport (art 41% wide, links at 58.9%), and grid
     percentages resolve against the padded track instead — which put the
     art at 23% of the screen rather than 41%. */
  /* Starts below the bar rather than covering it: in the open state the
     desktop bar stays visible and hosts the CLOSE X (1124:15223). */
  .menu {
    display: block;
    top: var(--navbar-h);
    padding: 0;
    /* Down from behind the bar instead of up from the bottom: on desktop
       the bar stays visible above the overlay and owns the toggle, so the
       panel reads as dropping out from under it. */
    transform: translateY(-100%);
  }

  .menu[data-open="true"] {
    transform: translateY(0);
  }

  /* The bar's own control closes it on desktop */
  .menu__close {
    display: none;
  }

  /* Tall left column running the full height below the nav, so the fixed
     mobile aspect ratio is dropped here. */
  .menu__art {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 41%;
    max-width: none;
    height: auto;
    aspect-ratio: auto;
    margin: 0;
  }

  .menu__art-cover {
    top: 50%;
    width: 36%;
  }

  .menu__art-buy {
    left: 50%;
    top: 63%;
    transform: translateX(-50%);
    padding: 2px 5px 3px;
    font-size: 24px;
    border-width: 1px;
  }

  .menu__inner {
    position: absolute;
    left: 58.9%; /* measured off the state render */
    top: 50%;
    transform: translateY(-50%);
    gap: 64px;
    margin: 0;
    padding-right: var(--pad-side);
  }

  /* Fixed label gutter rather than a flex gap: in the design both link
     columns start at the same x regardless of label width, which a gap
     cannot do since "Publication" and "Socials" differ in width. */
  .menu__group {
    display: grid;
    grid-template-columns: 70px 1fr;
    align-items: start;
    gap: 0;
  }

  .menu__list {
    width: auto;
    text-align: left;
  }

  .menu__list a {
    font-size: 30px;
  }

  .menu__close {
    position: absolute;
    top: 15px;
    right: var(--pad-side);
    padding: 0;
  }
}

/* ── Subscribe ── */
/* Mobile 1448:18509, desktop 1423:18314.

   Rebuilt. The first pass came from 842:327 on the superseded mobile
   page, which titled the section "Subscribe / Writers. On Substack.
   Groundbreaking." over a 12px paragraph and carried no desktop values
   at all. Both canonical frames say "Community" over one paragraph.

   Widths (360 / 297 / 202 on mobile, 767 / 506.5 / 326 on desktop) are
   maxima here so the row shrinks on narrow screens instead of
   overflowing. */
/* The section is now only the seam and the centring; the box inside owns the
   block's own layout. Its gap moved to .subscribe__box with the border. */
.subscribe {
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* Box around the whole block, asked for 2026-08-27. **There is no design for
   this** — every value below is derived from what the block already had, so it
   is all open to a designer's revision:

     border   1px solid --color-text, matching .subscribe__input's own rule
              exactly, since that rule is the only line in this block and a box
              of a different weight would read as a second, competing one.
     padding  20 mobile / 48 desktop. 20 is --pad-side's value at 680+, so the
              inner inset matches the page's own gutter rather than inventing a
              number; 48 is a little under the 50 the box's own gap sets between
              the copy and the form, which keeps the corners from feeling
              tighter than the interior.
     width    100% with a max-width, which is what makes it responsive: the box
              tracks the column down to the smallest phone and stops growing
              once it has cleared its contents.

   The max-width is the widest child plus the padding and borders, so the box
   hugs the measure instead of spanning the section. .subscribe__body caps at
   360 mobile / 767 desktop and is the wider of the two children either way
   (the form caps at 337 / 506.5), hence 360 + 40 + 2 = 402 and
   767 + 96 + 2 = 865. Re-derive if either cap moves.

   Below ~402 the cap never binds, so on a phone the box spans the content
   column and the copy reflows narrower than it did unboxed — that is inherent
   to boxing it, not a bug. */
.subscribe__box {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 31px; /* 1448:18509 gap */
  width: 100%;
  max-width: 402px;
  padding: 20px;
  border: 1px solid var(--color-text);
}

/* Held the star, the COMMUNITY heading and the body as one stack (1448:19079,
   gap 10). The star and heading were removed 2026-08-26, so this wraps the body
   alone and the gap is inert — kept as the value the three were spaced by, and
   because this is where a restored heading would go. `.subscribe__star` and
   `.subscribe__title` went with them; both were used only on index.html. */
.subscribe__intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px; /* 1448:19079 / 1423:18315 gap */
}

/* 1448:19070 — MW/MOBILE/BODY MOBILE, 15/1.1. Raised to 18 on request 2026-08-25 to match
   .litscene__item-title, then to the launch-party caption on request 2026-08-27. It sets the
   same --type-body-size / --type-body-lh pair .party__caption does, so the two move together
   if the token does. Family, weight and word-spacing already matched that caption — size and
   leading were the only differences.
   Desktop is untouched: the 960 block below sets its own 44/1.
   The copy reflows a line or two longer at 26. .subscribe__box's 402 cap is unaffected,
   because that is derived from this rule's max-width, which has not moved. */
.subscribe__body {
  max-width: 360px;
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: var(--type-body-size); /* 26px */
  line-height: var(--type-body-lh); /* 1 */
  text-align: center;
}

/* 1448:19072 is 297.06 wide: the 202 field, 7px, then the 88.06 button.
   Not 289 — that is 1448:19073, the field's wrapper, which stops before
   the button. The cap goes on the whole row and the button keeps its
   intrinsic width, so the field takes whatever is left. */
/* Bottom-aligned rather than offset from the top. The field is a bare rule, so
   the button's bottom border continues that rule as one line — which is how
   both frames draw it (mobile button rows 11-33 against a rule on row 33;
   desktop 9-53 against a rule on row 53). Anchoring to the top with the frames'
   6/9px offsets instead drops the button ~2.5px below the rule, because Figma's
   button height excludes its stroke where border-box includes it.
   This was `stretch` while the field was a closed box (2026-08-25) and came
   back with the rule on 2026-08-26. If the field ever becomes a box again, this
   goes back to stretch — a bordered button bottom-aligned against a bordered
   field draws border around empty space above it. */
/* The 297 became 337 on request 2026-08-25, a 20% larger field: the button
   keeps its intrinsic width, so the whole 20% lands on the field (200.71 to
   240.9) and the cap is the field's target plus the 8px gap plus the button. */
.subscribe__form {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  width: 100%;
  max-width: 337px;
}

/* 1448:19074 — rule under the field only, no box. It was a closed 1px box for
   a day (2026-08-25) and went back to the rule on request 2026-08-26, at both
   breakpoints, which is what the frame draws.
   Padding is asymmetric on purpose. Left stays 0 so the text sits flush with
   the start of the rule, the way 1448:19075 / 1423:18322 set it. Right is
   inset so a typed address stops short of the button instead of running into
   it — the frames only ever show the 5-character "Email" placeholder, so they
   never exercise a full field. There is no bottom padding: the type sits near
   the rule rather than centred between two borders.
   Note the top padding does not set the clearance on its own — an input centres
   its text in whatever content box is left, so the gap above the rule is half
   the slack. Here that is (40 - 7 - 21.6) / 2 = 5.7px.
   Scaled 20% on request 2026-08-25 and rounded to whole pixels: 34 to 41,
   15 to 18, 10 to 12. The gap and the form's cap moved with it; the button
   deliberately did not. */
.subscribe__input {
  flex: 1 1 auto;
  min-width: 0;
  height: 41px;
  /* No bottom padding: the text sits close to the rule. */
  padding: 7px 12px 0 0;
  border: 0;
  border-bottom: 1px solid var(--color-text);
  border-radius: 0;
  background: none;
  color: var(--color-text);
  font-family: var(--font-serif);
  word-spacing: var(--word-space-serif);
  font-size: 18px;
  line-height: 1.2;
}

.subscribe__input::placeholder {
  color: var(--color-placeholder);
  font-style: italic;
  opacity: 1; /* Firefox dims placeholders by default */
}

/* The field is a ruled line, not a box, so the browser's default focus ring —
   a 1.5px black outline around the whole input — reads as a stray border that
   the design does not have. Swapped for a heavier rule in the accent colour,
   which keeps focus obvious without drawing a box.
   Never just `outline: none`: this replaces the indicator rather than removing
   it. #ee3288 on white is about 3.9:1, over the 3:1 WCAG 2.4.11 wants, and a
   1px black rule going to 2px pink is unmistakably a state change.
   No layout shift, because the reset makes every box border-box: the extra
   pixel comes out of the content box rather than the field's outer size.
   Figma has no focus state for this field, so the treatment is invented.
   Keyed to :focus rather than :focus-visible on purpose — a text field is about
   to be typed into however it was reached, so it should always show the state,
   and that is also when the UA drew its own ring. */
.subscribe__input:focus {
  /* Transparent rather than none: forced-colors mode repaints a transparent
     outline, so the indicator survives there even though the accent rule below
     would be overridden. Invisible in normal rendering. */
  outline: 2px solid transparent;
  outline-offset: 2px;
  border-bottom-width: 2px;
  border-bottom-color: var(--color-pink);
}

/* 1448:19080 is the shared MOBILE BUTTON, so these are the same values as
   .feature__cta and the nav CTA — bordered, white, Arial Narrow. The old
   build had it filled black in the serif, which was the 842 frame. */
.subscribe__submit {
  flex: 0 0 auto;
  padding: 1.4px 3.5px 2.1px;
  background: var(--color-bg);
  border: 1px solid var(--color-text);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: 16.948px;
  line-height: 1.1;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}

/* Fills black on hover, and on its own once the field holds a whole address —
   the button reads as ready before you reach it.
   
   The second half is CSS rather than script: the field is `required` with a
   `pattern`, so :valid is false while it is empty or half-typed and true only
   once the address is complete. The pattern is also what makes "complete" mean
   something — type=email alone accepts "a@b" with no dot — and it applies to
   submission too, which restores the strictness the old JS gate used to add.
   
   :has() is the only modern piece here. Where it is unsupported the auto-fill
   simply does not happen and hover still does, which is the right way round. */
.subscribe__submit:hover,
.subscribe__submit:focus-visible,
.subscribe__form:has(.subscribe__input:valid) .subscribe__submit {
  background: var(--color-text);
  color: var(--color-bg);
}

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

@media (min-width: 960px) {
  /* Body copy down to the email field. 50, rounded up from Figma's 49
     (1423:18314). Lives on the box rather than the section since 2026-08-27,
     with the border — the section no longer has two children to space. */
  .subscribe__box {
    gap: 50px;
    max-width: 865px; /* 767 body + 2x48 padding + 2 borders */
    padding: 48px;
  }

  /* 1423:18318 — MW/DESKTOP/HEADING DESKTOP, 44/1, used for the body copy.

     THE MARGIN-TOP IS GONE, and that is the whole spacing story of removing the
     heading. It was 40, which with .subscribe__intro's own 10px flex gap made
     the 50 Figma sets between the title and this copy (1423:18314's 49, rounded
     up). With the title removed this is the first thing in the intro, so that
     margin no longer sat between two elements — it sat on top of the section's
     own 89px padding and pushed the copy 40px further down, opening the seam
     above the block from 178 to 218. Deleting it puts the body exactly where the
     star used to start, which is what keeps the white space unchanged. Restore
     it with the heading, not before. */
  .subscribe__body {
    max-width: 767px;
    font-size: 44px;
    line-height: 1;
  }

  /* 1423:18319 is 506.52 wide: the 326 field running straight into the
     180.52 button with no gap at all, unlike mobile's 7px. */
  .subscribe__form {
    gap: 0;
    max-width: 506.5px;
  }

  /* 32px, cut from 44 on request 2026-08-25 because 44 read as oversized. 44 is
     what 1423:18322 sets, but that is the size of the word "Email" in a frame
     that never shows a typed address, and it is also larger than the 34.85 the
     button beside it sets — so the field's type outsized the control it sits
     in. 32 lands just under the button and the two read as one set.

     It buys back capacity, which 44 was spending badly: a 325.9px field holds
     about 13 characters of an address at 44 against about 19 at 32, measured.
     Still not a whole address — see the blockers table.

     `line-height: 1` was load-bearing at 44 and is not at 32: the inherited 1.2
     would give a 38.4 line box, which the content box holds comfortably. It
     stays for predictable centring, and it is now slack rather than a
     constraint, so there is room to move the size again in either direction.

     The 10px top is what sets the type's distance from the rule, and it is not
     the distance itself: with no bottom padding the input still centres its
     text in the content box, so the gap is half the slack —
     (53 - 10 - 32) / 2 = 5.5px, which is exactly what 1423:18321 draws (the
     44px "Email" sits at y 4.5 in a 54 frame, so 5.5 above the rule).
     The 53 is the 54 height less the 1px rule. Re-derive it if the height, the
     font size or the line-height moves. */
  .subscribe__input {
    height: 54px;
    padding: 10px 16px 0 0;
    font-size: 32px;
    line-height: 1;
  }

  /* 1423:18323 — the same bordered button at 2.056x */
  .subscribe__submit {
    padding: 2.904px 7.259px 4.356px;
    border-width: 1.452px;
    font-size: 34.85px;
  }
}

/* ── Footer ── */
/* Mobile 1448:18525 (389×722 at y 6878), desktop 1271:16953 (1887.5 wide
   at y 7066). Two stacked pieces: the `gravity 2` animation and the
   VOLUME 0 wordmark. gravity 2 is a video fill Figma will not export, so
   only the wordmark is built — see the commented slot in the markup.

   The 100px below the wordmark is the white the mobile frame leaves under
   it (wordmark bottom 7278.3, frame bottom 7378). It sits on top of the
   var(--navbar-h) `body` already reserves for the fixed bottom bar, so
   the wordmark clears the nav instead of butting against its top border —
   which is how the 1448:18530 annotation ("make sure that this sits nicely
   at the bottom of the page with the bottom nav feature") reads. */
.footer {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 13px; /* 1448:18530 top (347.9) minus gravity 2 bottom (334.9) */
  /* No padding above the animation. Figma spaces the gallery 48 off the footer
     top on mobile (6829.9 to 6878) and the desktop gap was never measurable —
     see below — but the gravity stage opens with empty air the objects fall
     through, so any padding on top of that reads as dead white space. */
  padding-top: 0;
  /* 0, down from 40 on 2026-08-28. This used to be the only thing between the
     wordmark and the fixed bottom nav, one for one against body's own
     var(--navbar-h) padding-bottom. `.footer-legal` sits right after this
     element now on every page, and Figma's contact frame measures only 8px
     between the wordmark box and that row (408.375 - 400.375), not 40 — so
     the old 40 would have opened a gap 32px too wide. `.footer-legal`'s own
     top padding supplies that 8px instead; see its rule below. Desktop needs
     no equivalent change — its 35 already matched the wordmark *box*
     reaching exactly to where the legal row begins, so that padding stays. */
  padding-bottom: 0;
}

/* `gravity 2` — mobile 1448:18526, desktop 1271:16954. Both are 1880/990,
   i.e. the same 1.899 aspect, so one ratio serves both; what differs is the
   width it is drawn at. Desktop fills the frame. Mobile is 636.162 wide in a
   390 frame, so it bleeds ~123px each side — the poster frame confirms this is
   the same scene at the same icon sizes, just a wider stage than the screen,
   which puts the walls the pile stacks against off-screen. .footer clips it so
   the overflow never reaches the document and gives the page a sideways
   scrollbar.

   The stage has to be a real box before the engine can measure it, hence
   aspect-ratio rather than a height set from the contents: the icons are
   absolutely positioned and would otherwise collapse it to nothing. */
.footer {
  overflow-x: clip;
}

.footer__gravity {
  position: relative;
  /* Fills the window, but never narrower than 636.162 (1448:18526). The floor
     is 636 in a 390 frame, i.e. deliberately wider than the screen, which puts
     the walls the pile stacks against out of sight — that only works while the
     window is narrower than 636. As a flat width it under-filled the moment the
     window got bigger: at 862 the stage sat 636 wide and centred, leaving ~113
     of white down each side with the pile bunched in the middle. max() keeps the
     bleed on a phone and fills everything above it.
     .footer clips the overflow so the bleed never reaches the document. */
  width: max(636.162px, 100%);
  /* Flat, not Figma's 1880/990 ratio. The ratio derived the height from the
     width, which was fine while the width was fixed — with a fluid width it
     grew the band to 454 at an 862 window. Explicit keeps the mobile height
     whatever the width does. Still a real box before the engine measures it,
     which is what the ratio was there for.

     304 as of 2026-08-24, after cutting to 288 and putting a little back. 288
     is close to the floor rather than a preference: Mobile does not behave like desktop here: shortening the stage
     also shortens the spawn band, which is a flat 35% of the height, so the
     objects start more overlapped and the pile settles *taller* — 220 deep at
     300 but 279 at 270, which overflowed the top edge. 288 is the tightest
     height measured to hold it. Re-measure the pile, do not just scale this. */
  height: 304px;
  /* Icons are painted by JS; nothing to see until then. */
  touch-action: pan-y;
}

/* One icon. JS owns `transform` — every frame writes translate + rotate — so
   nothing here may set it. Sizes come from the registry in site.js, not from
   CSS, because the physics body and the artwork have to agree exactly. */
.footer__gravity img {
  position: absolute;
  top: 0;
  left: 0;
  /* Off-stage until the first frame places it, so nothing flashes at 0,0 */
  transform: translate(-9999px, -9999px);
  transform-origin: center center;
  will-change: transform;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
}

.footer__gravity img:active {
  cursor: grabbing;
}

/* Near full bleed: 383.573 wide in the 390 frame, so ~3px a side rather
   than the 15px --pad-side the rest of the mobile page uses. */
.footer__wordmark {
  display: block;
  width: 100%;
  max-width: 1849px;
  padding: 0 3px;
}

.footer__wordmark img {
  display: block;
  width: 100%;
  height: auto;
}

@media (min-width: 960px) {
  /* 1271:16954 is 1888 wide in an 1887.5 frame — the full width, unpadded.

     Height is NOT Figma's 1880/990 up here, and deliberately so. Icon sizes are
     viewport-independent, so a wider stage spreads the same twenty objects into
     a flatter pile — 260px tall at 1000 wide, 219 at 1440 — while an
     aspect-derived height grows with the width. The two move in opposite
     directions, so the band ends up mostly empty on a big screen: 758px tall
     with 539 of dead air at 1440, and near 1000 tall at the full 1888.

     A flat height instead. 380 clears the tallest pile by roughly 120px, which
     reads as air the objects fell through rather than a gap, and it holds at
     every desktop width.

     800 was arrived at by eye, after 380 -> 1140 -> 570 -> 712.5 -> 995 -> 990
     -> 900. The 380 was
     originally chosen to keep the fall short precisely so the band would not
     open up to a third of a screen of white; that trade has been deliberately
     loosened to give the icons room to fall, so air above the pile is expected
     here rather than a regression. At 800 the stage sits inside a laptop viewport
     with room to spare, where the earlier 1140, 990 and 900 all met or passed
     it. Mobile went the other way and keeps Figma's 335, with smaller icons
     instead of a taller band. */
  .footer__gravity {
    width: 100%;
    /* 600 as of 2026-08-24, down from 800 in three passes and then back up a
       little — the desktop band had carried the most dead air of the two, 371px
       of it at a 1280 window, or 46% of the stage.

       The floor here is the settled pile, and the pile is deepest at the
       *narrowest* desktop width, because that is where it has least room to
       spread: 417-462 measured at 960 against 353-458 at 1280 and 319 at 1920.
       560 clears the worst of those by ~100 and still reads as a fall.

       Two things to know before cutting it again. Re-measure at 960, not at
       your own window — a wide one flatters this badly.

       And cutting the height buys less than it looks like it should, because
       the spawn band is a flat 35% *of this height*: a shorter stage packs the
       objects closer at spawn, which changes how deep they settle. Measured at
       1280, 612 gave a 393-deep pile with 219 of air and 560 gave a 340-deep
       pile with 221 — the pile flattened by almost exactly what the box lost,
       so the white above it did not move. At 960 the same cut went the other
       way and gained 90. Height alone is a blunt instrument here; decoupling
       spawn from the stage (dropping the objects in from above the band) is
       what would actually let this shrink. */
    height: 600px;
  }

  .footer {
    gap: 20px; /* 1271:16953 flex gap */
    /* Still not measurable, and now moot: 1271:16953 is absolutely positioned
       at y 7066 over a flow whose height Figma never fixes, so the designed gap
       cannot be derived from the frame. It was 96px to match the desktop
       .section rhythm; zero now, because the stage's own empty upper band is
       the gap. */
    padding-top: 0;
    /* No bottom bar to clear up here — the desktop nav is sticky at the
       top — so this is just the white the wordmark's own box carries:
       1271:16957 is 288.177 tall against a 252.765 wordmark. This still
       holds now that `.footer-legal` follows: the contact frame's legal row
       starts exactly at this box's bottom edge (0px further gap), and the
       row's own 32px top padding is what visually separates it from the
       wordmark — see that rule. */
    padding-bottom: 35px;
  }

  /* 1271:16959 is the same asset at the same size as the desktop masthead
     (1271:16962), so the two line up — hence the shared 1849px cap. */
  .footer__wordmark {
    padding: 0 var(--pad-side);
  }
}

/* ── Footer legal links ── */
/* Privacy Policy · Terms of Service — new 2026-08-28, first built for
   contact.html (4056:83 desktop, 4072:159 mobile in the copy file) and
   added to every page's footer the same day. Figma's own frame draws a
   third link, Contact, here too, but it moved into the main nav menu the
   same day, on request, leaving just the two legal documents in this row.
   A sibling of `<footer>` rather than a child of it: Figma's own frame has
   it as a sibling of the gravity+wordmark group, not nested inside it, and
   keeping it separate means `.footer`'s own `gap` (13/20, between gravity
   and the wordmark only) never has to stretch to cover a second, different
   gap value to this row.

   Mobile: 8px above (the frame gap, see `.footer`'s padding-bottom above)
   and 8px below, both from this element's own padding — nothing separates
   it from the fixed bottom nav beyond that, because body's own
   var(--navbar-h) padding is already reserved past it, same as it always
   was past the wordmark.

   No `--pad-side` here: both frames centre this row in a full-width box
   with no horizontal inset of their own — `justify-content: center` inside
   does the work instead. */
.footer-legal {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
  padding: 8px 0;
  /* Smaller than .t-caption's own 12px on mobile, on request 2026-08-28.
     Reset to --type-caption-size below 960px so the desktop row is
     untouched; .t-caption itself stays 12px everywhere else it's used. */
  font-size: 10px;
}

.footer-legal a {
  color: inherit;
  text-decoration: none;
}

@media (min-width: 960px) {
  /* 32 above, 40 below (4056:83) — the row is the last thing on the page at
     this breakpoint (no fixed nav to clear), so its own bottom padding is
     the page's final trailing whitespace, not a gap to something else. */
  .footer-legal {
    padding: 32px 0 40px;
    font-size: var(--type-caption-size);
  }
}

/* ── Scroll Reveal ── */
/* Elements with [data-reveal] fade in when scrolled into view.
   Baseline is visible: if JS fails or motion is reduced, nothing hides. */
.js-reveal [data-reveal] {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity 500ms ease,
    transform 500ms ease;
}

.js-reveal [data-reveal].is-visible {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .js-reveal [data-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
