/* Website v6 — single stylesheet, top-bar navigation only, artboard model. */
/* geometry.css and proof-sheet.css are inlined here by builder/styles.js (W1),
   at this position, so style.css is one file. Their text is part of style.css,
   so the ?v= on the /style.css link covers any change to them. */
/* Generated by build.js from GEOMETRY. Do not edit by hand. */
:root {
  --ref-width: 1440px;
  --page-w: 720px;
  --page-h: 960px;
  --margin-outer: 6%;
  --margin-gutter: 4%;
  --margin-head: 72;
  --margin-foot: 100;
  --topbar-h: 56px;
  --sidebar-w: 240px;
  --spread-gap: max(5vh, 44px);
  --ground: 6vw;
  /* Stable copy of the desktop ground fraction - style.css's mobile block
     (max-width:1024px) reassigns --ground to 0 for the height:auto feed/
     Option C routes, but the page-composed P1-fit geometry needs the real
     desktop value back, so it reads from this untouched token instead of a
     second hardcoded literal. */
  --ground-base: 6vw;
  --photo-block-pad: 0;
  --run-end: 72;
  --strip-inset: 28px;
  --strip-inset-phone: 10px;
}
/* Shared proof-sheet geometry. Widths and heights come from proof-sheet.js. */
.proof-sheet {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  /* The site and CMS may provide a capped measure; proofing supplies only
     its own local measure. Keep the shared sheet usable without site tokens. */
  width: var(--proof-sheet-measure, 100%);
  max-width: none;
  gap: var(--proof-sheet-gutter);
  transform-origin: top left;
}

.proof-sheet .photo-row {
  position: relative;
  display: flex;
  flex: 0 0 auto;
  align-items: flex-start;
  gap: var(--proof-sheet-gutter);
  width: var(--proof-sheet-measure, 100%);
  min-height: 0;
}

/* Contact-sheet cells: 1px box around each row, drawn as an overlay so it
   adds no width and the solver's flush right edge is untouched. */
.proof-sheet .photo-row::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid var(--line);
  pointer-events: none;
}

.proof-sheet .photo-cell {
  position: relative;
  display: block;
  flex: 0 0 auto;
  min-width: 0;
  min-height: 0;
  overflow: visible;
  padding: 0 !important;
  background: none;
  text-decoration: none;
  line-height: 0;
}

.proof-sheet .photo-cell img {
  display: block;
  width: 100%;
  height: 100%;
  max-width: none;
}

.proof-sheet .photo-num {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 2;
  display: block;
  margin: 0;
  padding: 0;
  background: none;
  color: var(--apparatus);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.2;
  letter-spacing: .08em;
  pointer-events: none;
}

.proof-sheet .photo-num.select {
  color: var(--text);
}

.proof-sheet .photo-caption {
  position: absolute;
  top: calc(100% + 25px);
  right: auto;
  bottom: auto;
  left: 0;
  z-index: 2;
  display: block;
  max-width: 100%;
  margin: 0;
  padding: 0;
  background: none;
  color: var(--apparatus);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.2;
  letter-spacing: .08em;
  text-transform: uppercase;
  pointer-events: none;
}

/* Contact-sheet hairline: 1px rule in each gutter, meeting the row baseline. */
.proof-sheet .photo-cell:not(:last-child)::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  right: calc(var(--proof-sheet-gutter) / -2);
  width: 1px;
  background: var(--line);
  pointer-events: none;
}

/* ── type (2026-07-30) ─────────────────────────────────────────────────────
   Two self-hosted OFL families, no network request:
     mono  — IBM Plex Mono. ALL apparatus: folios, captions, plate numbers,
             nav, footer, section headers, colophon, and the cover word.
     serif — Source Serif 4. Everything with a voice: series titles and
             running prose. Variable on weight (300-700), so one file covers
             a 96px title and 19px body. Weight axis only — the optical-size
             axis is not in this instance.
   Both fall back to what the site rendered before (Courier New / Georgia).
   Cormorant Garamond @import removed: render-blocking, EU GDPR exposure,
   and used by nothing. */
/* 2026-07-30 — files fetched and these blocks corrected to match them.
   The previous declarations pointed at two variable files that were never
   downloaded, so BOTH faces silently fell back and the whole type system was
   rendering as Courier New / Georgia. Plex Mono has no variable build on npm
   or Google Fonts (static only), so it ships as two static weights — the two
   the apparatus actually uses. Latin subsets; anything outside Latin-1 falls
   back, which is correct for this site. */
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/fonts/IBMPlexMono-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/fonts/IBMPlexMono-500.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Source Serif 4';
  src: url('/fonts/SourceSerif4-Variable.woff2') format('woff2');
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --bg:      #1f1f1f;
  --surface: #2a2a2a;
  --text:    #d4d4d0;
  --muted:   #8f8a84;
  --accent:  #b85c45;  /* error and CMS only, no longer a site UI colour */
  --line:    rgba(212,212,208,.18);
  /* 2026-08-22 (his call). One field under the whole site. This was #141414,
     a step darker than --bg, and the page was the lighter object floating on
     it. That fill difference is what made every route read as a slab dropped
     in a dark room, Photographs and Words worst because their content stops
     mid-screen and the slab's bottom edge showed. The ground and the page are
     now the same colour everywhere - Index, About, series, treatments and the
     three volumes - and the container is drawn as a line instead of a fill
     (see --page-edge). Kept as its own token rather than deleted: the two
     surfaces are still separate ideas, they just resolve to one value. */
  --ground-surface: var(--bg);
  /* The artboard/page fill. A few shades under the ground so the page reads as
     an object again instead of dissolving into it. Dial this, not --bg. */
  --page-surface: #191919;
  /* The container's edge. With the ground and the page at one colour this is
     the only thing that says where the book is, so it gets its own dial
     instead of riding on --line, which also draws tables, rules and dividers.
     Turn it up to make the page more of an object, down to dissolve it. */
  --page-edge: rgba(212,212,208,.42);
  --apparatus: #a5a09a;
  --chrome: var(--surface);
  --border: var(--line);
  /* type stacks — every mono/serif reference in this file resolves here */
  --mono:  'IBM Plex Mono', 'Courier New', Courier, monospace;
  /* 2026-08-09 (provisional, Opus's call) - Source Serif 4 over Georgia for the
     serif voice. Georgia is a 1996 screen face with a large x-height; Source
     Serif 4 is a text face with a real 300-700 variable axis, which is what an
     excerpt at 11-13px in a small box needs. SourceSerif4-Variable.woff2 is
     already @font-face'd (line 36) and downloaded on every page but was never
     rendered, so this is zero new network cost. Provisional: the owner makes the
     real type decision later. Do not change any other type token. */
  --serif: 'Source Serif 4', Georgia, 'Times New Roman', serif;
  /* 2026-07-30 — apparatus text is at 11px; --muted (4.82:1) goes soft there.
     This is 6.35:1 against --bg: legible at 11px without going bright. */
  /* Page-module values come from geometry.css, generated by build.js. */
  --chrome-pad: 80px;   /* artboard-wrap padding-top, mobile only (see max-width:1024px block) */
  /* 2026-07-30 - the ground. Horizontal inset around the spread, in
     viewport width - not %, so it works inside the --measure calc() below
     - visible on each side, so the page reads as an object sitting on a
     field instead of being the screen itself. Zeroed on mobile (see the
     max-width:1024px block) where the artboard is a height:auto flex feed
     and an inset column is meaningless. */
  /* Flat field, one step darker than --bg, no texture/gradient/blur. This
     is a deliberate placeholder so the page (--bg) reads as the lighter
     object on a darker ground - the owner picks the real ground treatment
     after seeing this structural version; do not add texture here without
     his call. */

  /* 2026-07-30 - spread refactor stage 1. Each spread is now its own sized
     box in a vertical .rail (below), not a shared transform on one tall
     artboard, so --measure is the single source of width for the whole
     site: the spread, and the five non-paged pages (photographs / archive
     / about / writing / journal) all read their content width from it.
     2026-08-14 - sidebar refactor (spec SIDEBAR-NAV-2026-08-14.md): the
     fixed left sidebar replaces the topbar on desktop, so the --topbar-h
     term drops and the left ground is the sidebar width (--sidebar-w),
     while the right ground stays at var(--ground). Same stable-edge rule
     as before, just a different chrome. --u is one 1440-reference pixel, so
     every SIZE (not position) build.js bakes as calc(N * var(--u)) scales
     with the spread instead of needing a re-measure. */
  --measure: min(calc(100vw - var(--sidebar-w) - 2 * var(--ground)),
                 calc((100vh - 2 * var(--spread-gap)) * 1.5));
  --u: calc(var(--measure) / 1440);
  /* 2026-08-16 (Card C, §1.11) - the horizontal homepage gap. Defaults to the
     vertical --spread-gap so day-one behaviour matches; K3's tuning is one
     token edit here with no logic change. Not forked onto per-selector values. */
  --spread-gap-x: var(--spread-gap);
}

html {
  -webkit-text-size-adjust: 100%;
  -moz-text-size-adjust: 100%;
  text-size-adjust: 100%;
  scroll-snap-type: y mandatory;
  scroll-padding-top: 0;
}

/* 2026-07-31 - one ground under the whole document. The body was --bg (the
   PAGE colour) while .artboard-wrap and .topbar were --ground-surface (one step
   darker), so where the wrap ended and the footer began the two grounds met and
   drew a horizontal band across the bottom of every page - the "old theme
   colour" strip under the last spread. The ground is the ground everywhere now;
   pages are the lighter thing floating on it, which is the model the tokens
   already describe. */
html, body {
  background: var(--ground-surface);
  color: var(--text);
}

body {
  font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

a {
  color: inherit;
  text-decoration: none;
}

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

::selection {
  background: var(--text);
  color: var(--bg);
}

.artboard-text mark { background: var(--text); color: var(--bg); padding: 0 0.12em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }

/* ── top bar ─────────────────────────────────────────────────────────────── */

/* 2026-07-30 - nav restyle (his call: "its too tiny, like in this weird in
   between space"). The dead middle gap was space-between with nothing to
   fill it; flex-start plus a fixed 2.5rem gap between name and nav closes
   it. topbar-name/topbar-nav are direct flex children with no inner
   wrapper in the markup, so the content column is confined to the same
   var(--measure) width every page reads (name lands on the same x as
   every page's content edge) with padding-inline on the bar itself,
   rather than width+margin-inline:auto on a wrapper that doesn't exist -
   the ground-surface background below still spans edge to edge. */
.topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 2.5rem;
  padding-inline: calc((100% - var(--measure)) / 2);
  /* the bar reads as the ground the page sits on, not as UI chrome - same
     surface as --ground-surface, no hard edge underneath. */
  background: var(--ground-surface);
  z-index: 200;
  transition: transform 0.3s ease;
}

.topbar-hidden {
  transform: translateY(-100%);
}

/* 2026-08-14 - sidebar refactor (spec SIDEBAR-NAV-2026-08-14.md). On desktop
   the fixed left sidebar replaces the topbar, so the bar is hidden from
   1025px up. Below 1024px the topbar stays exactly as it always was; the
   sidebar never appears there (see the max-width rule further down). */
@media (min-width: 1025px) {
  .topbar {
    display: none;
  }
  body {
    padding-left: var(--sidebar-w);
  }
  /* 2026-09-23 (BOLD pass, Move 1) - the book fills the screen: fixed 28px
     air on every edge instead of the old 6vw ground / max(5vh,44px) gap,
     so the spread reads as the dominant object in the window rather than a
     slab floating in a wide field. --measure (style.css:117) already reads
     both tokens with the same formula it always used; only the values
     change, so photographs/archive/words/about keep reading their width
     from the one shared token with no separate edit. */
  :root {
    --ground: 28px;
    --spread-gap: 28px;
  }
}

/* ── sidebar (desktop) ─────────────────────────────────────────────────────── */
/* 2026-08-14 - fixed left sidebar navigation. Permanent furniture: no close,
   no toggle, no auto-hide. position:fixed so it never scrolls - the page
   content scrolls past it. Lives in the ground (--ground-surface), same as
   the old topbar. The build.js-emitted markup:
     aside.sidebar > .sidebar-name, .sidebar-rule, .sidebar-label,
     nav.sidebar-nav > a*, .sidebar-rule, .sidebar-meta                 */
.sidebar {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  width: var(--sidebar-w);
  background: var(--ground-surface);
  /* box-sizing: border-box (global reset) absorbs this into --sidebar-w -
     no shift to --measure or padding-left elsewhere. */
  border-right: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  /* padding-top aligned to the page box top = the rail's first air, which
     on desktop is now just var(--spread-gap) (no topbar term). */
  /* 2026-09-23 - fixed: the tighter 28px desktop gap made this 1px. */
  padding-top: 24px;
  padding-left: 24px;
  padding-right: 24px;
  z-index: 200;
}

.sidebar-name {
  font-family: var(--mono);
  font-size: 16px; /* 2026-10-09, his call: was 13px */
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--apparatus);
}

.sidebar-rule {
  height: 1px;
  background: var(--line);
  margin: 12px 0;
}

.sidebar-nav {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.sidebar-nav a {
  font-family: var(--serif);
  font-size: 19px;
  line-height: 1.3;
  color: var(--text);
  display: block;
  width: max-content;
  border-bottom: 1px solid transparent;
}

.sidebar-nav a:hover {
  border-bottom-color: var(--line);
}

/* 2026-08-22, his call: the active chapter is marked by a hairline under it,
   never by making the type lighter. Every chapter reads at one weight. */
.sidebar-nav a.active {
  border-bottom-color: var(--text);
}

.sidebar-fs-btn {
  display: flex;
  width: max-content;
  min-height: 44px;
  align-items: center;
  justify-content: flex-start;
  text-align: left;
  background: none;
  border: none;
  color: var(--apparatus);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 16px;
  letter-spacing: .14em;
  text-transform: uppercase;
  cursor: pointer;
  padding: 14px 0;
  margin-top: calc(1.25rem - 14px);
  margin-bottom: -14px;
  transition: color .15s ease;
}
.sidebar-fs-btn:hover { color: var(--text); }

.sidebar-return {
  font-family: var(--serif);
  font-size: 15px;
  color: var(--text);
  text-decoration: none;
  margin-top: 16px;
}

.sidebar-return:hover {
  text-decoration: underline;
}

.sidebar-meta {
  font-family: var(--mono);
  font-size: 12px; /* 2026-10-09, his call: was 10px; tracking eased so the
                      address still fits the 192px sidebar measure */
  line-height: 1.4;
  letter-spacing: 0.05em;
  color: var(--muted);
}

/* 2026-10-08 - Evolved's slimmer register (redesign/evolved/evolved.css):
   the name is the way home, the nav sits closer under the rule, and the
   contact lines stack under the second rule. No label, issue line or Full. */
.sidebar-name { width: max-content; }
a.sidebar-name:hover { color: var(--text); }
.sidebar-nav { margin-top: 4px; }
.sidebar-contact { display: flex; flex-direction: column; gap: 6px; }
.sidebar-contact .sidebar-meta { display: block; width: max-content; }
a.sidebar-meta:hover { color: var(--text); }

.topbar-name {
  font-size: 1.25rem;
  font-weight: 500;
  color: var(--text);
}

.topbar-nav {
  display: flex;
  gap: 1.5rem;
}

/* 2026-08-08 - the register groups the masthead and the nav links together so
   the mobile rule can scale the whole row as one object. Above 640px the
   wrapper has no box (display: contents), so desktop computed geometry is
   byte-identical to before this wrapper existed. */
.topbar-register {
  display: contents;
}

.topbar-nav a {
  font-size: 0.8125rem;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  font-family: var(--mono);
  color: var(--muted);
  padding-bottom: 4px;
  border-bottom: 1px solid transparent;
  transition: color 0.15s ease;
}

.topbar-nav a.active {
  color: var(--text);
  border-bottom-color: currentColor;
}

/* fullscreen toggle button — lives at the end of .topbar-nav */
.fs-btn {
  background: none;
  border: none;
  color: var(--muted);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0 0.5rem;
  transition: color 0.15s ease;
}

.fs-btn:hover { color: var(--text); }

/* 2026-09-23 (BOLD pass, Move 2), his call - the phone/tablet register
   collapses to one row: five links only (Index added - previously only
   the sidebar carried it), mono apparatus type, no masthead title, no
   fullscreen toggle. --topbar-h drives every downstream mobile measurement
   (book window offset, --m-half-air, apparatus routes' padding-top), so
   changing it here is the one edit the rest of the cascade already reads. */
@media (max-width: 1024px) {
  :root {
    --topbar-h: 44px;
  }

  .topbar {
    height: auto;
    min-height: var(--topbar-h);
    padding-inline: 14px;
  }

  .topbar-name,
  .fs-btn {
    display: none;
  }

  .topbar-register {
    display: flex;
    width: 100%;
  }

  .topbar-nav {
    display: flex;
    flex-direction: row;
    flex-wrap: nowrap;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    gap: 0;
  }

  .topbar-nav a {
    font-size: 11px;
    letter-spacing: 0.06em;
    padding-bottom: 0;
    white-space: nowrap;
  }

  /* The return link (TREATMENT_RETURN_JS) only fills in on a /t/ visit; when
     empty it must not claim a sixth slot in the space-between row. */
  .topbar-return[hidden] {
    display: none;
  }
}

#book { transition: opacity 120ms ease; }
.nav-out #book { opacity: 0; }

/* fullscreen active: hide chrome but keep the toggle reachable as the exit */
.topbar.fs-active {
  background: transparent;
  border-bottom-color: transparent;
  pointer-events: none;
  /* .topbar-hidden applies a transform on scroll, which makes .topbar a
     containing block and drags the fixed .fs-nav-panel up with it mid-scroll.
     Fullscreen nav must stay put regardless of scroll position. */
  transform: none !important;
}

.topbar.fs-active .topbar-name,
.topbar.fs-active .topbar-nav a {
  visibility: hidden;
}

.topbar.fs-active .fs-btn {
  pointer-events: auto;
}

.fs-nav-reveal,
.fs-nav-panel { display: none; }

/* 2026-07-31 - the fullscreen menu spoke a different language to the rest of
   the site: a grey plate with a boxed button and browser-default sans links, in
   a document that is otherwise mono apparatus type on a bare ground. It is
   apparatus now, same as the folio, the frame numbers and the proof sheet
   label. No plate, no border, no box - the words sit on the work. */
.topbar.fs-active .fs-nav-reveal {
  display: block;
  position: fixed;
  top: 1.25rem;
  left: 1.5rem;
  z-index: 2;
  color: var(--apparatus);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  pointer-events: auto;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  transition: color .15s ease;
}

.topbar.fs-active .fs-nav-reveal:hover { color: var(--text); }

/* the reveal button and the panel's own close button share the same top-left
   position by design (one stable anchor point, label swaps instead of
   sliding) - reveal must hide once the panel is open or the two labels
   render stacked on top of each other. aria-expanded is already flipped to
   "true" by the existing open/close JS, so no script change is needed. */
.topbar.fs-active .fs-nav-reveal[aria-expanded="true"] { visibility: hidden; }

.topbar.fs-active .fs-nav-panel.open {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1;
  min-width: 0;
  padding: calc(var(--topbar-h) + var(--spread-gap)) 1.5rem 1.75rem;
  background: transparent;
  border: 0;
  pointer-events: auto;
}

/* the links are the one place the serif is allowed - they are content, not
   apparatus, and they read as a contents page rather than a nav bar. */
.fs-nav-panel nav {
  display: flex;
  flex-direction: column;
  gap: .55rem;
  margin-top: 0;
}

.fs-nav-panel a {
  color: var(--muted);
  text-decoration: none;
  font-family: var(--serif);
  font-size: 1.35rem;
  line-height: 1.15;
  letter-spacing: -0.01em;
  transition: color .15s ease;
}

/* 2026-07-31 - the way back to the cover. Mono apparatus, not serif: it is not
   a section of the issue, it is the masthead, so it reads as a different order
   of thing and sits above the section list with a rule between them. */
.fs-nav-panel a.fs-nav-home {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--apparatus);
  padding-bottom: .75rem;
  margin-bottom: .35rem;
  border-bottom: 1px solid var(--line);
}

.fs-nav-panel a:hover { color: var(--text); }
.fs-nav-panel a.active { color: var(--text); }

.fs-nav-close {
  display: block;
  margin-top: 2rem;
  color: var(--apparatus);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  transition: color .15s ease;
}

.fs-nav-close:hover { color: var(--text); }

/* 2026-07-31 - an unfilled half of a pair. Nothing drawn at all: the pair's own
   geometry still reserves the space, so the spread keeps its composition while
   the slot waits for a photograph. Was two broken-image icons on the published
   homepage. */
.pair-img-empty {
  display: block;
  background: none;
  border: 0;
}

/* ── artboard ────────────────────────────────────────────────────────────── */

.artboard-wrap {
  overflow: hidden;
  width: 100%;
  /* 2026-07-30 - spread refactor: no padding-top here any more. Each
     spread used to live inside one scaled artboard that this wrap inset
     from the top (see build.js SCALER_JS, now deleted); the vertical
     rhythm is now owned entirely by .rail's own padding below, so the air
     above the first spread and below the last is set once, in one place.
     What stays here is just the full-width ground surface showing through
     around and between spreads. Reset to var(--chrome-pad) on mobile,
     where this is a height:auto flex feed again (max-width:1024px block). */
  padding-top: 0;
  background: var(--ground-surface);
}

/* 2026-07-30 - spread refactor stage 1. The vertical list of spreads.
   Lifted from .opencode/mockup-spreads.html. Padding top/bottom is set so
   the visible air above the first spread equals the air below the last -
   his words: the page sits on the ground like an object on a table. */
.rail {
  --rail-top: calc(var(--topbar-h) + var(--spread-gap));
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--spread-gap);
  padding: var(--rail-top) 0 var(--spread-gap);
}

/* 2026-08-14 - desktop (≥1025px, no topbar) rail override. Placed AFTER
   the default .rail block so it wins the cascade: margin-left shifts the
   rail right of the fixed sidebar, and the top air loses the topbar term. */
@media (min-width: 1025px) {
  .rail {
    --rail-top: var(--spread-gap);
  }
}

.artboard {
  position: relative;
  /* width comes from .spread below (var(--measure)) - every spread div
     carries both classes, so this rule and the next one apply together. */
  overflow: hidden;
  /* explicit page surface - was previously just the html/body background
     showing through; made explicit now that the wrap around it has its
     own, different, ground color. */
  background: var(--page-surface);
}

/* 2026-07-30 - spread refactor stage 1. Each spread sizes itself to the
   viewport independently (the thing one shared artboard transform could
   not do). Lifted from the mockup, with one deviation: width is
   var(--measure) - the site-wide token - not the mockup's inline min().
   container-type is not lifted; nothing in this stage reads a container
   query unit (--u is a plain calc(), see :root). position:relative,
   overflow:hidden and background come from .artboard above, since every
   spread carries both classes - not repeated here. */
.spread {
  width: var(--measure);
  height: calc(var(--measure) * var(--page-h) / var(--ref-width));
  aspect-ratio: 1440 / 960;
  scroll-snap-align: center;
}

/* ── weather rendering (Phase 3B) ────────────────────────────────────────── */
/* build.js emits the wrapper as the FIRST child of a .spread only when a
   surface value (amount, vignette or gutter) is non-zero; with no weather
   anywhere no weather DOM exists and the spread is byte-identical to the
   pre-weather baseline. It is the first positioned child (z-index 0, tree
   order), so it paints BELOW .artboard-el and links, and below
   the page-frame ::after overlay, keeping the printed edge clean. All
   value-bearing styles (grain SVG data URLs, tile size, layer opacity,
   gradients) are inline from build.js; these rules own only geometry and
   normal blending - no mix-blend-mode anywhere (third-build rule). No
   pointer events: weather never intercepts input. */
.artboard.spread > .artboard-weather,
.page > .artboard-weather {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  border-radius: inherit;
}

.artboard-weather .aw-grain {
  position: absolute;
  inset: 0;
  background-repeat: repeat;
}

/* Cancels the mean lift the grain pair adds over a dark ground. */
.artboard-weather .aw-level {
  position: absolute;
  inset: 0;
}

.artboard-weather .aw-vignette {
  position: absolute;
  inset: 0;
}

.artboard-weather .aw-gutter {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 14%;
  transform: translateX(-50%);
}

/* Air (Phase 3B) - a delta on the following gap, not a replacement for it.
   .rail's `gap: var(--spread-gap)` is preserved; a spread with air=A adds
   (A - 1) * gap below itself, so the total spacing under it becomes A * gap.
   --air is emitted inline by build.js only when the spread's resolved air
   differs from its parent's, so absent --air (air 1) keeps this at 0 and the
   baseline spacing is untouched. Excluded on the final spread - there is no
   following gap to scale. Desktop-only: below the 1024px breakpoint the
   Option C page-box block owns .artboard.spread's margin-bottom
   (--m-page-gap) and --spread-gap is 0, so air is suppressed on mobile by
   construction and must not participate in the mobile page stack. */
@media (min-width: 1025px) {
  .artboard.spread:not(:last-child) {
    margin-bottom: calc((var(--air, 1) - 1) * var(--spread-gap));
  }
}

/* 2026-07-30 - C4. The page box for flow-content routes (photographs,
   archive, about-legacy, writing index, journal). Mirrors .spread above
   exactly: same width token, same 1440/960 box, same snap. .spread gets
   position/overflow/background from .artboard, which these routes do not
   carry, so they are declared here instead of repeated by accident.
   Padding is the print margin: 6% outer (--margin-outer) and the
   72/100 head and foot from the page module, expressed in --u so they
   hold their proportion to the box the way every baked artboard size
   does. The box NEVER stretches and NEVER scrolls internally - overflow
   is moved into a second identical box by the paginator (C4.3). */
.page {
  position: relative;
  overflow: hidden;
  background: var(--page-surface);
  width: var(--measure);
  aspect-ratio: 1440 / 960;
  scroll-snap-align: center;
  padding: calc(var(--margin-head) * var(--u)) var(--margin-outer) calc(var(--margin-foot) * var(--u));
}

/* The proof sheet is the photographs page's content, not an inset inside it.
   2026-07-31 - one container, one sheet. The page is a flex column: header
   at its natural height, grid taking every remaining pixel. */
.photographs-page {
  --page-inner: var(--measure);
  padding-inline: 0;
  /* 2026-07-31 - the sheet is the page. The book's 72/100 head and foot are
     for a text page with a running head; a proof sheet has neither, and
     reserving them left a band of dead air above the header and below the
     last row. Now zero: the header strip sits flush inside the container's
     top edge and the last row lands on its bottom edge, so there is no seam
     between container and sheet on any side. --photo-block-pad comes from
     GEOMETRY (build.js), the same constant solvePhotoGrid() solves against,
     so the box and the grid that fills it are always working from one
     number. */
  padding-block: calc(var(--photo-block-pad) * var(--u));
  display: flex;
  flex-direction: column;
  /* Floor: every proof sheet occupies at least one shared page box. */
  min-height: calc(var(--page-h) / var(--ref-width) * var(--measure));
}

/* The printed edge. Same hairline as .artboard.page-frame::after
   (style.css:326-333) but WITHOUT the centre spine: a spread is two
   facing pages meeting, a flow page is a single leaf, so there is no
   seam to draw. Drawn as an overlay so it adds zero width and the left
   edge still lands on the same x as the topbar name. */
.page::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px solid var(--page-edge);
  pointer-events: none;
}

.artboard-el {
  position: absolute;
}

.artboard-el img {
  display: block;
  width: 100%;
  height: auto;
  cursor: pointer;
}

.artboard-el video {
  display: block;
  width: 100%;
  height: auto;
}

/* ── artboard frames (Card I) ─────────────────────────────────────────────── */
/* Opt-in per-element hairline border. Text also gets 14px padding (boxed
   text); images get border-only (printed-plate look). The `framed` class
   is emitted by build.js only when el.frame === true AND the site-wide
   kill switch (site.frames) is not false — so absent or frames:false
   produces byte-identical output. Inert unless .framed is present. */
.artboard-el.framed {
  border: 1px solid var(--line);
}

.artboard-el.framed:not(.artboard-text) {
  /* kill default line-height baseline gap below the contained IMG so
   hairline hugs the photo edge (2026-07-27 fix). display-mode kept
   as default block — overriding to inline-block would conflict with
   .artboard-el's position:absolute drag/resize math (Card C2). */
  line-height: 0;
}

.artboard-text.framed {
  padding: 14px;
}

/* Per-layout page frame: outer edge only. 2026-07-30 - spread refactor: this used to also draw a
   repeating horizontal hairline at every var(--page-h) interval, because
   one .artboard held N stacked pages under one shared transform. Now each
   spread IS one page-pair (see .spread above), so there is only ever one
   seam to draw - the spine - and the repeating gradient goes with the old
   model. Rendered as a single pseudo-element so it is never serialized
   into element data - it's a class on the .artboard div driven by
   data.pageFrame. Inert unless .page-frame is present (build.js omits it
   on the cover spread only - see the cover section below). */
.artboard.page-frame::after {
  content: none;
}

.artboard.page-frame { box-shadow: inset 0 0 0 1px var(--page-edge); }

/* An explicit spread spine is independent of the printed outer edge. */
.artboard.spread-spine::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 1px;
  background: var(--line);
  pointer-events: none;
}

/* ── the cover (2026-07-30) ───────────────────────────────────────────────
   Opt in with "cover": true in the layout JSON.

   The cover keeps the SAME #1f1f1f field as every other page - it is not a
   different material, it is the first page. What makes it a cover is that
   it is empty except for one word. So this flag does exactly one thing:
   suppress the page frame across the first page, because a cover carries
   no printed border. 2026-07-30 - spread refactor: each spread is its own
   box now, so this is simply build.js not emitting the page-frame class
   on spread 0 when cover is true (renderArtboard) - no CSS offset needed,
   the shift-the-gradient-down hack this used to be is gone. */

/* the cover word: mono caps, wide-tracked. Opt in per element with
   style:"cover". His call on the word itself - this only defines the
   setting. */
.artboard-text-cover {
  font-family: var(--mono);
  font-weight: 500;
  font-size: calc(88 * var(--u));
  line-height: 1.0;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text);
}
@media (max-width: 1024px) {
  body:not(.page-composed) .artboard-text-cover {
    /* !important: an authored inline font-size (build.js emits calc(N *
       var(--u)) from el.fontSize) would otherwise defeat this clamp and
       clip the cover word on a phone (same defect + remedy as
       .artboard-text-headline below). Desktop >1024px is untouched. */
    font-size: clamp(2rem, 11vw, 5.5rem) !important;
    letter-spacing: 0.14em;
  }
}

/* ── artboard fit rows (Card H1) ─────────────────────────────────────────── */
/* AUTO-FIT: full-width image rows get capped to one page. 2026-07-30 -
   spread refactor: each spread is now its own sized box (see .spread
   above), so the cap that used to come from a viewport-scaler-set custom
   property (build.js, now deleted) is just 100% of that box - the spread
   IS the available space, nothing left to measure. The element box keeps
   its artboard footprint (left/top/width) and top edge; the image
   shrink-wraps to the page height and centers horizontally inside the
   box. Side matting (gaps) is acceptable; cropping is not. */
.artboard-el.fit {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  max-height: 100%;
}

.artboard-el.fit img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

/* G8: a drawn image box owns its declared bounds. Content contains inside
   that box, including any authored mat, rather than reading the spread height.
   An intentionally empty box stays visible on the public page. */
.artboard-el.fit-box {
  display: flex;
  box-sizing: border-box;
  justify-content: center;
  align-items: center;
  border: 1px solid var(--line);
}

.artboard-el.fit-box img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

.artboard-el.fit-box > a {
  display: flex;
  max-width: 100%;
  max-height: 100%;
}

/* ── artboard fill modes (Card J) ─────────────────────────────────────────── */
/* Opt-in per-element fullscreen fit. Only emitted by build.js when
   el.fit === 'fill' (absent / 'free' = byte-identical legacy). The
   auto-fit `fit` class (Card H1) is suppressed for any element with an
   explicit `fit`, so the two never coexist on one element. Aspect
   decides the variant:
     landscape (aspect ≥ 1.5) → fill-cover: edge-to-edge, contained
       and centered - no crop, side matting if the aspect differs.
     portrait/square/4:3 → fill-height: height clamped, width auto,
       centered horizontally in the element box — NEVER crops.
   Both clamp height to 100% of the spread. 2026-07-30 - spread refactor:
   one spread is now exactly one page tall (see .spread above), so 100% of
   the spread's own height IS one page - the old scaler-set clamp and the
   page-height clamp it was combined with both collapse to the same "one
   page" reservation build.js already bakes into elementBottom, so the
   live fit and the baked flow still never disagree. The element box gets
   height:100% first - a percentage height on the img alone resolves
   against the box's own auto height and collapses to auto, so the
   wrapper has to set it before the image reads it. */
.artboard-el.fill-cover {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}

.artboard-el.fill-cover img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

.artboard-el.fill-height {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: flex-start;
}

.artboard-el.fill-height img {
  width: auto;
  height: 100%;
  max-width: 100%;
  object-fit: contain;
  display: block;
}

/* Below 640px viewport width, fill elements render at natural height
   (mobile stays in normal flow — no fullscreen snapping). The wrapper's
   inline width:% is preserved; the image just fills that width at its
   natural aspect. */
@media (max-width: 640px) {
  body:not(.page-composed) .artboard-el.fill-cover img,
  body:not(.page-composed) .artboard-el.fill-height img {
    width: 100%;
    height: auto;
    max-width: none;
    object-fit: contain;
  }

  body:not(.page-composed) .artboard-el.fill-height {
    display: block;
  }
}

/* ── artboard text ───────────────────────────────────────────────────────── */

.artboard-text {
  color: var(--text);
  white-space: pre-wrap;
  transform-origin: top left;
}

/* Card T7 — vertical text (margin type). Opt-in via el.vertical:true. */
.artboard-text.vertical {
  writing-mode: vertical-rl;
  text-orientation: mixed;
}

/* 2026-07-31 - THE CMS/PUBLISHED PARITY BUG. Every artboard text size below was
   an absolute px/rem value while every size build.js bakes per element is
   calc(N * var(--u)) - so a sized element scaled with the container and an
   unsized one did not. The CMS canvas is 1440 wide (--u == 1); a published page
   is --measure wide (--u < 1 on a laptop), so the same paragraph rendered
   ~19% larger relative to its box on the site than in the editor. It wrapped
   narrower, outgrew the min-height the CMS measured and wrote back, and
   collided with the absolutely-positioned element beneath it - the overlapping
   prose he screenshotted. Values are the same numbers expressed in 1440-
   reference px, so the CMS canvas is byte-identical and only the published page
   changes. The canvas and the page box are one object; a size that ignores --u
   breaks that outright. */
/* G4 — display type step: cover/title scale, tight leading. Opt-in via
   style:"display"; existing tokens below are untouched. */
.artboard-text-display {
  /* Card F1 — serif display stack. Freight Display / PP Editorial New are
     named first for if/when those webfonts get licensed and loaded; until
     then every browser falls straight through to Georgia (system serif),
     so this is a safe no-dependency upgrade today. */
  font-family: var(--serif);
  font-size: calc(72 * var(--u));
  font-weight: 600;
  line-height: 1.0;
  letter-spacing: -0.015em;
}

.artboard-text-headline {
  font-size: calc(48 * var(--u));
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.01em;
}

.artboard-text-headline strong {
  font-weight: 700;
}

.artboard-text-title {
  font-size: calc(24 * var(--u));
  font-weight: 500;
  line-height: 1.25;
}

.artboard-text-body {
  font-size: calc(14.4 * var(--u));
  line-height: 1.6;
}

/* ── reading tokens (2026-07-30) ───────────────────────────────────────────
   The only structural difference is white-space, and it is load-bearing:
     prose - paragraphs reflow at every width. Line breaks are the browser's.
     poem  - authored line breaks are preserved. The break IS the form.
   No default measure: an ordinary element uses the full width of its own
   box. An explicit el.measure is the sole opt-in line-length cap, emitted
   inline by build.js as max-width:min(Nch, 100%). */
.artboard-text-prose {
  font-family: var(--serif);
  font-size: calc(19 * var(--u));
  line-height: 1.75;
  white-space: normal;
}

.artboard-text-prose > div,
.artboard-text-prose > p {
  margin-block: 0.9em;
}
.artboard-text-prose > div:first-child,
.artboard-text-prose > p:first-child { margin-block-start: 0; }
.artboard-text-prose > div:last-child,
.artboard-text-prose > p:last-child { margin-block-end: 0; }

.artboard-text-poem {
  font-family: var(--serif);
  font-size: calc(19 * var(--u));
  line-height: 1.9;
  white-space: pre-wrap;
}

.artboard-text-meta {
  font-size: calc(11 * var(--u));
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-family: var(--mono);
  color: var(--apparatus);
}

/* V6-CARD-2026-08-03 — authored text flow: unresolved overflow marker.
   Rendered by build.js at the end of the LAST flow fragment when content
   remains that no region received (resolveTextFlow().unresolved). Accent
   dot so the loss is legible at a glance. The flow is never clipped with
   overflow:hidden — the unresolved state is meant to stay visible. */
.flow-overflow-marker {
  color: var(--accent);
  font-family: var(--mono);
  font-size: calc(16 * var(--u));
  line-height: 1;
  margin-left: 0.25em;
}

/* CMS-FLOW-AUTHORING-2026-08-14 — the "[flow unresolved — N lines remain]"
   span is emitted by build.js for view-source debugging; it is a development/
   CMS diagnostic and must not appear on the live site. The CMS has its own
   rule that shows it with accent colour. */
.flow-unresolved-notice { display: none; }

/* 2026-08-14 — links inside an apparatus block (contact, colophon): no
   underline, no box, the link is simply the one thing in the block set at
   full text weight and the rest sits back at apparatus. The quietest
   possible affordance, which is the only kind this page tolerates. */
.artboard-text-meta a { color: var(--text); transition: opacity .15s ease; }
.artboard-text-meta a:hover { opacity: .7; }

 /* ── scroll progress line ────────────────────────────────────────────────
    Thin horizontal track at the top edge. Grey track, fills white as the
    page/book scrolls. Purely positional, no animation. Native scrollbar hidden. */
 html { scrollbar-width: none; }
 html::-webkit-scrollbar { display: none; }
 /* 2026-08-22 - under view-horizontal #book is the scroller, so it grows its
    own bar. Same treatment: the progress indicator is the read-out. */
 #book { scrollbar-width: none; }
 #book::-webkit-scrollbar { display: none; }
 .scroll-track {
   position: fixed;
   top: 0;
   left: 0;
   width: 100%;
   height: 2px;
   background: var(--line);
   z-index: 400;
   pointer-events: none;
 }
 .scroll-fill {
   width: 2%;
   height: 100%;
   background: var(--apparatus);
 }

/* ── the index (2026-08-14) ───────────────────────────────────────────────
   Each route is its own artboard element (style: index-entry), individually
   selectable and editable in the CMS. The element box IS the row — flex
   layout with name left, route right, border treatment. */
.artboard-text-index-entry {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  height: 100%;
  border-bottom: 1px solid var(--line);
  color: inherit;
}

.artboard-text-index-entry .ix-name {
  font-family: var(--mono);
  font-weight: 500;
  font-size: calc(14 * var(--u));
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text);
}

.artboard-text-index-entry .ix-route {
  font-family: var(--mono);
  font-size: calc(11 * var(--u));
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ── G1: plate+folio/caption binding ──────────────────────────────────────
   Optional per-image caption/folio, rendered directly below the plate.
   Only emitted by build.js when el.caption and/or el.folio are set — inert
   otherwise (no markup, so no visual change). */
.plate-caption {
  font-family: var(--mono);
  font-size: 11.2px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--apparatus);
  display: flex;
  gap: 12px;
  align-items: baseline;
}

.plate-folio {
  flex-shrink: 0;
}

.plate-caption-text {
  flex: 1;
}

/* ── G1: pair element — two images bound as a unit, shared rank ────────── */
.artboard-el.pair {
  display: flex;
  flex-direction: row;
  gap: 2px;
  overflow: hidden;
}
.artboard-el.pair .pair-img {
  width: calc(50% - 1px);
  height: 100%;
  object-fit: contain;
  flex-shrink: 0;
  display: block;
}
.artboard-el.pair .pair-img[src=""] {
  opacity: 0.15;
}

/* ── Card W: text boxes as links ────────────────────────────────────────── */
.artboard-link {
  color: inherit;
  text-decoration: none;
  display: block;
}

.artboard-link:hover {
  color: var(--text);
}

/* ── Q1: Typography toggles ────────────────────────────────────────────── */
.caps { text-transform: uppercase; }
.tracking-wide { letter-spacing: 0.12em; }
.leading-loose { line-height: 1.8; }
.leading-tight { line-height: 1.25; }
.artboard-text.inverse { color: var(--bg); }
.dropcap::first-letter {
  font-size: 3.2em;
  float: left;
  line-height: 0.9;
  padding-right: 0.08em;
}
.artboard-text.barred { border-left: 1px solid var(--line); padding-left: 14px; }

/* ── Card T4: text background backing ──────────────────────────────────── */
.artboard-text.bg-solid { background: var(--bg); padding: 8px 12px; }
.artboard-text.bg-translucent { background: rgba(38,36,33,0.85); backdrop-filter: blur(2px); padding: 8px 12px; }

/* ── Q4: rule divider element ──────────────────────────────────────────── */
.artboard-rule {
  border-top: 1px solid var(--line);
}

/* ── Card L: mobile linearization (phone feed) ──────────────────────────── */
/* Phones and tablets ≤1024px: a spread's elements are absolutely positioned
   in percentages of a two-facing-page box; left alone that box collapses
   to zero height on a narrow screen (absolutely positioned children don't
   contribute to auto height) and its contents stack illegibly on top of
   each other. Instead, the page becomes a DERIVED vertical scroll feed:
   every artboard element keeps its DOM + composed order, but the
   .artboard switches to a row-wrap flex flow with all elements at 100%
   basis (visually a column, but enables 50/50 pairing in Card M via pure
   data - no rework needed here). Visual y-order is rebuilt with
   `order: var(--rank, 0)` emitted by build.js (Card L). Desktop is
   byte-identical: every override is !important and gated by this media
   query; --rank is inert outside it. /photographs/ grid is not an
   artboard - untouched. */
/* 2026-07-30 - raised 700 -> 1024. A tablet is a page, not a shrunken
   spread. */
@media (max-width: 1024px) {
  /* 2026-07-30 - collapse the whole per-spread box model below this
     breakpoint. Mobile is a height:auto flex feed with its own
     --feed-margin; a spread rail, a spread gap and a fitted measure all
     mean nothing there. --u collapses to 1px so every inline
     calc(N * var(--u)) size build.js bakes (Card L) reads back as its raw
     N-pixel value; anything that needs different mobile treatment already
     has its own !important override below. */
  :root {
    --ground: 0;
    --spread-gap: 0;
    --sidebar-w: 0; /* 2026-08-14 - no sidebar on mobile; --measure keeps its
                       pre-sidebar desktop value as empty as possible. */
    --measure: 100%;
    --page-inner: var(--measure);
    --u: 1px;
  }

  /* 2026-08-14 - sidebar is desktop-only; the topbar (unchanged below 1024px)
     is the mobile nav. */
  .sidebar {
    display: none;
  }

  /* Snap-scrolling and the spread rail are desktop-only; the feed rules
     further down take over completely. */
  html {
    scroll-snap-type: none;
  }

  .rail {
    display: block;
    padding: 0;
  }

  body:not(.page-composed) .spread,
  .page {
    aspect-ratio: auto;
    width: 100%;
    height: auto;
  }
  /* C11: blank spreads are intentional pages, not collapsed feed gaps. */
  body:not(.page-composed) .spread:not(:has(.artboard-el)) {
    aspect-ratio: 1440 / 960;
  }
  .page::after { display: none; }
  .page { padding-inline: 0; overflow: visible; }

  /* Pickup: flow indexes remain pages below the feed breakpoint. Their
     content measure keeps the shared 6% inner margin without adding a
     second inset to photographs or any artboard feed route. */
  .journal-page {
    padding-inline: var(--margin-outer);
  }

  /* Card AJ — auto-hiding topbar, mobile-first.
     2026-08-08, his call: retired. Once the register became legible and
     padded he wanted it present at all times, with every page sitting
     below it. The class is still applied by NAV_TOGGLE_JS and still drives
     nothing else, so neutralising the transform here is the whole change -
     no JS is removed and the behaviour can be restored by deleting this
     override. */
  body.nav-tucked .topbar {
    transform: none;
  }

  .artboard-wrap {
    height: auto !important;
    padding-top: var(--chrome-pad);
    overflow-x: hidden;
  }

  /* Vertical rules don't linearize — hide in mobile feed */
  body:not(.page-composed) .artboard-rule.vertical-rule { display: none; }
  /* Link overlays: collapse height, show as visible tappable row */
  body:not(.page-composed) .artboard-link-overlay {
    height: auto !important;
    min-height: 44px;
    border-color: var(--line) !important;
    display: flex;
    align-items: center;
    padding: 0 4px;
  }

  /* position stays `relative` so Card I .page-frame::after keeps anchoring. */
  body:not(.page-composed) .artboard {
    width: 100% !important;
    height: auto !important;
    min-height: 0 !important;
    transform: none !important;
    /* belt-and-suspenders alongside --ground: 0 above - the scaler sets
       margin-left inline from --ground, this makes sure it can never
       shift the mobile feed even if that computation is ever wrong. */
    margin-left: 0 !important;
    display: flex;
    flex-flow: row wrap;
    align-content: flex-start;
    /* the page's outer margin on a phone, in viewport terms */
    --feed-margin: 7vw;
  }

  /* Mobile is a height:auto flex feed, so a fixed var(--page-h) grid means
     nothing here - suppress the page-division gradient and keep only the
     outer edge border, restoring the single-frame behavior. */
  body:not(.page-composed) .artboard.page-frame::after {
    inset: 10px;
    background-image: none;
  }

  body:not(.page-composed) .artboard-el,
  body:not(.page-composed) .artboard-link {
    position: static !important;
    left: auto !important;
    top: auto !important;
    margin: 0 var(--feed-margin) 26px;
    width: calc(var(--m-w, 100%) - (var(--feed-margin) * 2)) !important;
    flex-basis: calc(var(--m-w, 100%) - (var(--feed-margin) * 2)) !important;
    order: var(--m-order, var(--rank, 0));
  }

  body:not(.page-composed) .artboard-el img {
    width: 100% !important;
    height: auto !important;
    max-height: none !important;
    object-fit: contain !important;
    display: block;
  }

  body:not(.page-composed) .artboard-el video {
    width: 100% !important;
    height: auto !important;
    display: block;
  }

  /* Neutralize Card H1 auto-fit clamp inside the feed band. */
  body:not(.page-composed) .artboard-el.fit {
    display: block;
    max-height: none;
  }

  /* Neutralize Card J fill-cover / fill-height clamps inside the feed
     band. (Card J already reverts <640px at line 244; this covers the
     whole ≤700px band so the phone feed never crops.) */
  body:not(.page-composed) .artboard-el.fill-cover img {
    width: 100% !important;
    height: auto !important;
    object-fit: contain !important;
  }

  body:not(.page-composed) .artboard-el.fill-height {
    display: block;
  }

  body:not(.page-composed) .artboard-el.fill-height img {
    width: 100% !important;
    height: auto !important;
    max-width: none;
    object-fit: contain !important;
  }

  /* G8: desktop box pixels become a proportional feed box through the
     inline aspect ratio emitted from the same w/h data. */
  body:not(.page-composed) .artboard-el.fit-box {
    display: flex;
    height: auto !important;
    align-items: center;
  }

  body:not(.page-composed) .artboard-el.fit-box img {
    width: auto !important;
    height: auto !important;
    max-width: 100% !important;
    max-height: 100% !important;
    object-fit: contain !important;
  }

  /* Card H inline fixed height must NOT reserve in the feed (fixed h +
     overflow:visible would overlap the next flex item). */
  body:not(.page-composed) .artboard-text {
    /* margin now comes from .artboard-el above — no second inset */
    padding: 0;
    box-sizing: border-box;
    height: auto !important;
  }

  /* Card E4 - plate + caption are ONE unit in the feed. Pull the caption
     up against its plate and put the page air after the pair, so the
     reader sees a plate-with-caption rather than two loose items. build.js
     gives the caption the same --rank as its plate, so it always lands
     directly after. */
  body:not(.page-composed) .artboard-el.plate-caption {
    margin-top: -18px;
    margin-bottom: 40px;
  }

  /* Readable mobile defaults — NO !important so authored inline font-size
     (Card B) still wins. Headline/title are large by default, but an
     authored fontSize (e.g. 120px) can exceed the 375-430px phone
     viewport and clip. Cap with a fluid clamp() so it can never overflow
     the viewport width while still reading as a large display headline;
     the min bound keeps it well above body size, the vw term degrades it
     smoothly across phone widths instead of snapping at a breakpoint. */
  body:not(.page-composed) .artboard-text-headline {
    font-size: clamp(2.25rem, 12vw, 4.5rem) !important;
    line-height: 1.1;
  }

  body:not(.page-composed) .artboard-text-body {
    font-size: 16px;
    line-height: 1.65;
  }

  body:not(.page-composed) .artboard-text-meta {
    font-size: 0.75rem;
  }

  /* Q4: rule in feed — full-width hairline with normal element margins */
  .artboard-rule {
    border-top: 1px solid var(--line);
  }

}

/* ── lightbox ────────────────────────────────────────────────────────────── */
/* 2026-10-08 - restyled as the campaign-study viewer (spec
   specs/2026-10-08-horizontal-artboard-build.md; reference
   /Users/ronin/os/packages/2026-10-08-campaign-study/index.html). Dark full
   screen at the same rgba(16,16,15,.97), the frame fitted whole at its own
   aspect (object-fit: contain - never cropped), a mono caption bar, quiet
   round buttons. Phone: buttons in the caption bar; desktop with a pointer:
   arrows at the sides. Markup: build.js LIGHTBOX_HTML. */
html.is-viewing, html.is-viewing body { overflow: hidden; }

.lightbox {
  --vbar: 76px;
  position: fixed;
  inset: 0;
  z-index: 300;
  background: rgba(16, 16, 15, 0.97);
  color: var(--text);
  touch-action: pinch-zoom;
  overscroll-behavior: contain;
  opacity: 0;
  visibility: hidden;
  transition: opacity .25s cubic-bezier(.16, 1, .3, 1), visibility 0s linear .25s;
}
.lightbox.open {
  opacity: 1;
  visibility: visible;
  transition: opacity .25s cubic-bezier(.16, 1, .3, 1), visibility 0s;
}
.lightbox:focus { outline: none; }
.lightbox ::selection { background: var(--text); color: var(--bg); }

.lightbox .v-stage {
  position: absolute;
  inset: 0 0 var(--vbar) 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 56px 10px 6px;
}
.lightbox .v-stage img {
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  transition: opacity .2s cubic-bezier(.16, 1, .3, 1);
  -webkit-user-drag: none;
  user-select: none;
  cursor: default;
}
.lightbox .v-stage img.is-loading { opacity: .35; }

.lightbox .v-btn {
  position: absolute;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text);
  cursor: pointer;
  font-size: 0;
  transition: background-color .15s ease, opacity .15s;
}
.lightbox .v-btn .ic { width: 24px; height: 24px; }
.lightbox .v-btn:hover { background: rgba(212, 212, 208, .12); }
.lightbox .v-btn:disabled { opacity: .25; cursor: default; background: transparent; }
.lightbox .v-btn:focus-visible { outline: 1px solid var(--text); outline-offset: 2px; }
.lightbox .lb-close { top: max(8px, env(safe-area-inset-top)); right: 8px; }
.lightbox .lb-prev,
.lightbox .lb-next { bottom: calc((var(--vbar) - 48px) / 2); }
.lightbox .lb-prev { left: 6px; }
.lightbox .lb-next { right: 6px; }

.lightbox .v-cap {
  position: absolute;
  left: 60px;
  right: 60px;
  bottom: 0;
  height: var(--vbar);
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  text-align: center;
  font-family: var(--mono);
  font-size: 11.2px;
  line-height: 1.5;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--apparatus);
  text-wrap: balance;
}
.lightbox .v-cap .v-line { display: block; min-width: 0; }
.lightbox .v-cap .n { color: var(--text); white-space: nowrap; }
.lightbox .lb-info-title { color: inherit; text-decoration: none; text-transform: none; }
.lightbox a.lb-info-title { color: var(--text); text-decoration: underline; text-underline-offset: .22em; text-decoration-thickness: 1px; }
.lightbox a.lb-info-title:hover { color: var(--apparatus); }

@media (min-width: 1000px) and (hover: hover) {
  .lightbox { --vbar: 64px; }
  .lightbox .v-stage { inset: 0 96px var(--vbar) 96px; padding: 28px 0 4px; }
  .lightbox .lb-prev,
  .lightbox .lb-next { top: 50%; bottom: auto; margin-top: -24px; }
  .lightbox .lb-prev { left: 24px; }
  .lightbox .lb-next { right: 24px; }
  .lightbox .lb-close { top: 16px; right: 24px; }
  .lightbox .v-cap { left: 96px; right: 96px; }
}
@media (max-height: 480px) and (orientation: landscape) {
  .lightbox { --vbar: 44px; }
  .lightbox .v-stage { inset: 0 64px var(--vbar) 64px; padding: 6px 0 2px; }
  .lightbox .lb-prev,
  .lightbox .lb-next { top: 50%; bottom: auto; margin-top: -24px; }
  .lightbox .lb-prev { left: 8px; }
  .lightbox .lb-next { right: 8px; }
  .lightbox .lb-close { top: 6px; right: 8px; }
  .lightbox .v-cap { left: 64px; right: 64px; font-size: 10.5px; }
}
@media (prefers-reduced-motion: reduce) {
  .lightbox, .lightbox.open, .lightbox .v-stage img { transition: none; }
}

/* ── series page ─────────────────────────────────────────────────────────── */

.series-page {
  padding-top: 80px;
  max-width: 900px;
  margin: 0 auto;
}

.series-header {
  padding: 3rem 1.5rem 2rem;
}

.series-title {
  font-family: var(--serif);
  font-size: 2rem;
  font-weight: 500;
  letter-spacing: -0.01em;
}

.series-meta {
  margin-top: 0.5rem;
  font-family: var(--mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--muted);
}

.series-prose {
  margin-top: 1.5rem;
  font-size: 0.9rem;
  line-height: 1.6;
  max-width: 60ch;
}

.series-images {
  display: flex;
  flex-direction: column;
  gap: 2rem;
  padding: 0 1.5rem;
}

.series-image img {
  width: 100%;
  height: auto;
  cursor: pointer;
}

/* ── about page ──────────────────────────────────────────────────────────── */

/* width/margin/padding now come from the shared five-page rule above.
   min-height/flex here still do their own job: centering the lines
   vertically in whatever's left below the topbar. */
.about-page {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1.25rem;
}

/* 2026-07-30 - spread refactor: the 50ch reading measure stays, but as an
   INNER max-width now, left-aligned to the shared container edge instead
   of centering itself in the viewport. Under align-items:stretch (the
   flex default), a child with a max-width narrower than the container
   still starts flush at the cross-start edge - so this reads exactly like
   the old centered-50ch box did, just shifted to the site's common left
   edge. */
.about-page > * {
  max-width: 50ch;
}

.about-line {
  font-size: 1.1rem;
  line-height: 1.6;
}

.about-availability {
  margin-top: 1rem;
  font-size: 0.9rem;
  color: var(--muted);
}

.about-capabilities {
  font-family: var(--mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--muted);
}

.about-city {
  font-family: var(--mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--muted);
}

.about-email a {
  color: var(--text);
  border-bottom: 1px solid var(--line);
  padding-bottom: 1px;
  font-size: 0.95rem;
}

/* ── footer ──────────────────────────────────────────────────────────────── */

/* L1 — Link overlay: transparent clickable block for artboard card navigation */
.artboard-link-overlay {
  display: block;
  text-decoration: none;
  cursor: pointer;
  z-index: 10;
  border: 1px solid transparent;
  transition: border-color 0.15s, opacity 0.15s;
}
.artboard-link-overlay:hover {
  border-color: var(--line);
}

/* ── archive rows ────────────────────────────────────────────────────────── */

/* 2026-07-30 - the 10% horizontal padding is gone. It was written when this
   page ran the full viewport width; the shared --measure container owns the
   inset now, and a further 10% each side stacked inside it. Vertical padding
   tightened 28 -> 20 because the row height is no longer driven by an
   unconstrained thumbnail (see .arc-image) - an index should fit six entries
   on a screen, not two. */
/* 2026-07-31 - one container, filled. The page is a flex column: head rule,
   list, colophon. The list takes every remaining pixel and its rows split it
   equally, so the index ends flush with the bottom of the page box instead
   of stopping halfway and paginating the remainder into a second box.
   Vertical padding on the row is gone - the row's height IS its share of the
   container now, and the content centres inside it. */
.page-archive .archive-page {
  display: flex;
  flex-direction: column;
}

/* ── mobile ──────────────────────────────────────────────────────────────── */

@media (max-width: 640px) {
  /* 2026-08-08, his call: the register was a shade too small and sat flush
     against both screen edges. Two lines are acceptable, so the row wraps
     instead of scaling itself down to fit, and the type goes back up to a
     legible size. --topbar-h is raised to match the two-line bar - every
     downstream measurement reads that token (the book window's top offset
     and --m-half-air both derive from it), so the page below stays centred
     without a second number to keep in sync.

     padding-inline was calc((100% - var(--measure)) / 2), and --measure is
     100% on mobile, so it resolved to zero - that is why UNMOORED touched
     x=0 and FULL ran to the exact right edge. */
  /* Register geometry itself moved to the shared max-width:1024px block
     above (Move 2, 2026-09-23) so it reads one row at every phone/tablet
     width instead of switching shape again at 640px. This query keeps only
     the rules below that are unrelated to the register. */

  /* Apparatus routes own their natural height and clear the mobile register
     in the final apparatus block below. */
  body:not(.page-composed) .photographs-page {
    padding-top: calc(var(--topbar-h) + 28px);
  }

  .series-page,
.about-page {
    padding-top: calc(var(--topbar-h) + 28px);
  }

  /* 2026-07-30 - the ONE mobile value. This replaces three competing
     declarations (80 / 140 !important / 96).
     2026-08-08 - it was a literal 140 (96px bar + 44 of air), which went
     stale the moment the register was resized. It now derives from
     --topbar-h, so the bar height is the single number and every route
     clears it automatically. Book routes zero this separately: #book is
     fixed below the bar already, so they must not pay it twice. */
  .artboard-wrap {
    padding-top: calc(var(--topbar-h) + 28px);
  }
}

/* ── generated index routes ──────────────────────────────────────────────
   Apparatus sizing is finalized in the route-gated block at the end of this
   file; these base selectors keep the generated markup readable in isolation. */
.page-archive .archive-page {
  padding: calc(12 * var(--u)) 0;
  display: flex;
  flex-direction: column;
}
.journal-page {
  padding-inline: calc(var(--margin-outer) / 2);
  padding-block: calc(var(--margin-outer) / 2);
  display: flex;
  flex-direction: column;
}

/* ── series ledger (archive/index) ─────────────────────────── */

/* 2026-07-31 - journal was the one flow page still on .page's full book
   margins, so its head sat deep inside the box while Words and Archive ran to
   the edge - three index pages, three different measures. It joins them above,
   and its running head matches theirs in size rather than staying at the
   small apparatus default. */
.journal-page > div:first-child .meta {
  font-size: 0.82rem;
  letter-spacing: 0.16em;
}

.journal-page .jn-entry .body {
  font-size: 1.05rem;
}

/* ── the page survives the phone (Option C, 2026-07-31, his call) ─────────
   The feed above linearizes a spread's contents correctly but leaves the
   spread itself at height:auto, so the box took its size from whatever was
   inside it - the exact inversion of the one rule this site is built on.
   Option C restores a real page container on mobile WITHOUT scaling the
   desktop composition down: the box is fixed and the content is redrawn at
   phone size to fit into it. Deliberately a separate media query placed
   after the feed block so it wins on equal specificity without a wall of
   !important, and so deleting these rules restores the old feed exactly.

   Applies to artboard spreads - homepage, series, treatments. The flow
   pages (photographs / archive / about / writing / journal) are a separate
   pass; they are already bounded by --measure and do not hug their content
   the same way. */
@media (max-width: 1024px) {
  :root {
    /* 2026-07-31 (his call) - "the containers are too tall, there is no
       padding, I want this to feel like it's on a table, just slightly."
       Gutter up so the page reads as an object lying on a surface rather than
       a full-bleed screen, and the page's own height is no longer a full
       viewport - see --m-page-h below. */
    --m-page-gutter: 6vw;   /* workspace either side of the page */
    --m-page-gap: 22px;     /* between one page and the next */
    --m-page-pad: 6vw;      /* the page's own inner margin */
    /* The page box was min-height:100svh minus chrome, so every container ran
       the full screen and the stack felt endless. A page is a leaf, not a
       screen: 78% of the available height leaves the next page's top edge
       visible, which is what makes it read as a stack of pages on a table. */
    --m-page-h: calc(
      (100svh - var(--topbar-h) - (var(--m-page-gap) * 2)) * 0.78
    );
  }

  html {
    /* one page per screen, the same gesture as the desktop spread rail */
    scroll-snap-type: y proximity;
  }

  .artboard-wrap {
    padding-inline: var(--m-page-gutter);
  }

  body.page-composed .artboard-wrap {
    padding-inline: 0;
  }

  /* The page box. 2026-08-01 (his call, reversing the 2026-07-31 decision
     below) - height, not min-height. min-height gave the box a floor but no
     ceiling, so a three-line page and a 700-word page were different sizes
     and the stack read ragged - exactly the "container sized by its
     contents" defect CLAUDE.md 0b rules out. Every page box is now the same
     height on every route; where a spread genuinely carries more than one
     screen, fewer/smaller frames is correct, an oversized box is not. The
     border is the printed edge, so the desktop page-frame overlay is
     switched off below to avoid drawing it twice. */
  body:not(.page-composed) .artboard.spread {
    box-sizing: border-box;
    border: 1px solid var(--line);
    padding-block: var(--m-page-pad);
    /* !important is load-bearing: the older mobile feed block (.artboard,
       line ~963) sets height:auto !important as a belt-and-suspenders
       against the desktop scaler, and a later plain declaration can never
       outrank an !important one regardless of source order or specificity.
       Without matching it, this whole rule silently no-ops and the spread
       is back to sizing itself from its content - verified 2026-08-01 by
       measuring rendered spread heights on / at 390px: they varied from
       93px to 2398px with this line as plain height. */
    height: var(--m-page-h) !important;
    margin-bottom: var(--m-page-gap);
    scroll-snap-align: start;
    scroll-margin-top: var(--topbar-h);
    /* 2026-08-01 - `safe` is the whole point. Plain `center` on a fixed-height
       box distributes overflow symmetrically, so a spread carrying more than
       one page of content bleeds off the TOP as well as the bottom and the
       clip takes a slice out of the middle. `safe center` centres identically
       whenever the content fits, and falls back to start-alignment the moment
       it does not, so overflow always runs off the bottom edge only. Container
       behaviour, not a content fix: the pages that overrun still overrun. */
    align-content: safe center;
  }

  body:not(.page-composed) .artboard.spread:last-child {
    margin-bottom: calc(var(--m-page-gap) * 2);
  }

  /* the real border above replaces the inset overlay frame */
  body:not(.page-composed) .artboard.page-frame::after {
    display: none;
  }

  /* The page's inner margin now lives on the box, so the per-element feed
     margin collapses - otherwise every element is inset twice. */
  body:not(.page-composed) .artboard.spread > .artboard-el,
  body:not(.page-composed) .artboard.spread > .artboard-link {
    --feed-margin: var(--m-page-pad);
  }

  /* Content gives way, not the container. Every frame on the page is capped
     at its SHARE of the page, not a fraction of the screen - --m-count is
     the element count build.js writes onto each spread. Three frames divide
     one page into three; one frame gets the whole page. Without the divisor
     each image caps independently and three of them stack to three screens,
     which is the content sizing the page again by a slower route.
     Contained and centred, so a frame is matted, never cropped. */
  body:not(.page-composed) .artboard.spread {
    /* 2026-07-31 - reads the shortened page height, not the viewport, or the
       elements inside would still be sized for a full-screen box and overflow
       the shorter one. */
    --m-page-inner: calc(var(--m-page-h) - (var(--m-page-pad) * 2));
    --m-share: calc(var(--m-page-inner) / var(--m-count, 1) - 26px);
  }

  /* !important is load-bearing here: the feed block above sets
     `max-height: none !important` on .artboard-el img to undo the desktop
     fit clamps, and without matching it the share cap silently never
     applies. */
  body:not(.page-composed) .artboard.spread .artboard-el img {
    width: auto !important;
    max-width: 100% !important;
    max-height: var(--m-share) !important;
    margin-inline: auto;
    object-fit: contain !important;
  }

  body:not(.page-composed) .artboard.spread .artboard-el video {
    max-height: var(--m-share) !important;
    margin-inline: auto;
  }

  /* A pair is two frames sharing one row, so it reads as one object on the
     page and takes one share, not two. */
  body:not(.page-composed) .artboard.spread .artboard-el.pair {
    max-height: var(--m-share);
  }

  /* Same box, every route. The flow pages are bounded horizontally by
     --measure but they were still height:auto with their printed edge
     switched off, so a short archive read and a long one were different
     objects. One container, one book: same border, same inner margin, same
     height - not a minimum (2026-08-01, see .artboard.spread above for the
     reasoning). Long prose that would overflow this box is the writing
     piece's job to paginate into more boxes, not this rule's job to grow. */
  .page {
    box-sizing: border-box;
    border: 1px solid var(--line);
    padding-block: var(--m-page-pad);
    padding-inline: var(--m-page-pad);
    height: var(--m-page-h);
    margin-bottom: var(--m-page-gap);
    scroll-snap-align: start;
    scroll-margin-top: var(--topbar-h);
    /* the older mobile feed block (line ~924) sets `.page { overflow:
       visible }` for the height:auto feed layout; this rule wins on source
       order for every other property here but overflow was never reasserted,
       so content past the fixed height above bled past the printed border
       instead of being contained by it (found 2026-08-01 on /writing/, whose
       index list runs longer than one page box). Restores the base desktop
       rule's overflow:hidden (line ~410) so the box never scrolls or bleeds
       - a route whose content doesn't fit needs fewer/shorter entries,
       which is a content fix, not a container one. */
    overflow: hidden;
  }

  /* the flow pages already carried their own inner margin; the page box
     owns it now, so drop the second inset rather than doubling it. */
  .journal-page,
.archive-page {
    padding-inline: var(--m-page-pad);
  }

  /* 2026-08-13 - archive was full-bleed on mobile because .page-archive
     .archive-page (two classes, line ~2189) sets padding: N 0 and outranks
     the single-class selector above. The inset itself now comes from the
     shared composed-route rule further down, which is where the other three
     routes get theirs; this only has to cancel the desktop rule's zero. */
  .page-archive .archive-page {
    padding-inline: unset;
  }

  /* the proof sheet is its own solved grid - it must keep its zero inline
     padding and flush edges, so it takes the border and the block padding
     from .page above and nothing else. */
  .photographs-page {
    padding-inline: 0;
  }
}

/* ── reduced motion ──────────────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
    animation: none !important;
  }
}

/* ── Treatment view: long ─────────────────────────────────────────────────
   2026-08-02 - Long is the same spreads with the seam between them removed,
   NOT a different layout and NOT a collapsed box. Every element inside a
   spread is absolutely positioned against that box, so the box keeps its
   exact height and aspect-ratio in both modes - releasing either one
   destroys the page. The only things that change are the gap in the rail
   (the site's equivalent of the CMS GAP constant) and the ground showing
   through between spreads. */

body.page-treatment.view-long .rail {
  gap: 0 !important;
}

body.page-treatment.view-long .artboard.spread {
  scroll-snap-align: none !important;
  /* 2026-08-02 - the site's equivalent of clipElementToBoard in the CMS. In
     paged mode the spread's overflow:hidden is what cuts an element at the page
     edge, which is correct there. Long mode has no page edges, so the cut goes.
     The box itself keeps its height and aspect-ratio - only the clip is
     released, never the sizing. */
  overflow: visible !important;
  /* Spreads are flex siblings each painting an opaque background, so a later
     one would cover the overflow of the one above it and the element would
     still LOOK cut, for a different reason. In long mode the page surface
     belongs to the rail as one continuous field instead. */
  background: transparent !important;
  /* 2026-08-02 - Phase 3D. Air scales the following rail gap, and long mode
     zeroes that gap, so air must not resurrect it as a margin. On desktop
     --m-page-gap is undefined (mobile-only token), so this resolves to 0 and
     the air-adjusted margin-bottom (style.css:450) is dead in long mode. The
     mobile media block below re-asserts its own 0/--m-page-gap because it
     comes later with equal specificity. */
  margin-bottom: var(--m-page-gap) !important;
}

/* Long mode keeps the treatment's outer definition without repeating page
   seams. The page-frame toggle still controls whether these borders exist. */
body.page-treatment.view-long .artboard.page-frame { box-shadow: inset 0 0 0 1px var(--page-edge); }

/* Long treatment mode has no page seams, but it still has two surfaces: the
   shared ground around the book and the treatment canvas inside it. Keep the
   rail at the shared measure so the palette cannot recolour the ground. */
body.page-treatment.view-long .rail {
  position: relative;
  width: var(--measure);
  max-width: 100%;
  margin-inline: auto;
  background: var(--ground-surface);
}

/* The rail owns the air above and below the long canvas. Paint the treatment
   surface only between those margins, so the canvas does not touch the nav. */
body.page-treatment.view-long .rail::before {
  content: '';
  position: absolute;
  z-index: 0;
  top: calc(var(--topbar-h) + var(--spread-gap));
  right: 0;
  bottom: var(--spread-gap);
  left: 0;
  background: var(--bg);
  pointer-events: none;
}

body.page-treatment.view-long .rail > .artboard.spread {
  z-index: 1;
}

/* 2026-08-02 - Phase 3D. Weather is per-page surface treatment (grain,
   vignette, gutter are all children of this wrapper), and long mode has no
   page surface - the rail is one continuous field. Suppressing the wrapper
   suppresses all three at once. The per-spread grain stays suppressed in long
   mode for now; a rail-level continuous grain is a future enhancement if he
   wants it. */
body.page-treatment.view-long .artboard.spread > .artboard-weather {
  display: none;
}

body.page-treatment.view-long { scroll-snap-type: none !important; }

/* Mobile carries its own page-box treatment - border, margin, shortened
   height - which is the seam on that surface. Remove the seam, keep the
   height.

   2026-08-08 - long mode stays continuous on mobile and does NOT take the
   one-page-per-screen gap the other composed routes take. Its elements are
   authored against one continuous coordinate space with no page seams, so
   paging it cuts across them: measured at 375px, 22 elements across 20
   spreads hang past a page bottom, up to 172px on a 220px box. Long mode
   was never the stacking complaint - it has no visible seams, so it reads
   as one document that is small, not as a stack. Small is what pinch is
   for. */
@media (max-width: 1024px) {
  body.page-treatment.view-long .artboard.spread {
    border: 0 !important;
    margin-bottom: calc(var(--page-box) * 0.02) !important;
    padding-block: 0 !important;
  }
  body.page-treatment.view-long .artboard.spread:last-child {
    margin-bottom: calc(var(--page-box) * 0.02) !important;
  }
  body.page-treatment.view-long .rail::before {
    top: 0;
  }
}

@media (max-width: 1024px) {
  /* 2026-08-08, his call, reversing the earlier split: photographs, words
     and journal were left on the old inset container (330 wide, outside the
     window, snapping to start) while the homepage moved into the book. Side
     by side they read as two different sites. Every composed route now gets
     the same treatment - full-width box, fixed window, centred, snapped.

     The one thing that must still differ is box HEIGHT: a spread is 3:2 and
     a document page is portrait, so --m-box-h below carries whichever the
     route actually uses, and all the air derives from that one number. */
  body.page-composed {
    --page-box: 100vw;
    --u: calc(var(--page-box) / 1440);
  }

  body.page-composed .artboard.spread {
    display: block;
    width: var(--page-box);
    height: calc(var(--page-box) * var(--page-h) / var(--ref-width));
    min-height: 0;
    margin-inline: auto;
    padding-block: 0;
    border: 0;
  }

  /* ── the book window ──────────────────────────────────────────────────
     The page takes the full width of the screen, ground shows above and
     below it and never beside it, #book is fixed below the register and
     owns the scroll, and the gap is the exact clearance that leaves one
     page on screen. Earlier shapes failed because the rail sat in document
     flow, so the page travelled up the screen and the reader chased it -
     first with a 4% gap (three spreads at once, a raw stack), then with a
     full-viewport gap (one at a time, but a screen of void to cross).
     Nothing here is transformed, cloned, or overlaid.

     --m-box-h is the height of whatever box this route uses. Artboard
     routes derive it from the 3:2 spread; document routes take --m-page-h
     (style.css:2188, 78% of the available viewport). :has() picks between
     them, which is the only place the two kinds of route still diverge.

     --m-half-air and --m-page-air are two different numbers, and conflating
     them once put the first page low. Half-air is the EXACT ground above a
     centred box: it is the rail's padding, so at scroll position 0 the
     first page is already centred with no scrolling needed. Page-air is
     that plus a margin, and it is the gap BETWEEN boxes, where a little
     slack keeps the next one from peeking at the window edge. */
  body.page-composed {
    --m-box-h: var(--m-page-h);
    --m-half-air: calc((100vh - var(--topbar-h) - var(--m-box-h)) / 2);
    --m-half-air: calc((100lvh - var(--topbar-h) - var(--m-box-h)) / 2);
    --m-page-air: calc(var(--m-half-air) + (var(--page-box) * 0.04));
  }

  /* 2026-08-08 (spec D1, completed) - the document routes join the spread
     branch. They are .page elements, not .artboard.spread, so :has() sent
     every one of them to the --m-page-h default and they became the only
     portrait boxes on the site. These selectors resolve to the same 1.500
     height from the same expression, so the box, the half-air above it and
     the gap between boxes are one number on every route - which is what
     makes a paginated /journal/ read as a stack of the same page rather
     than a scroll. (2026-08-15 - words and archive leave this group for the
     screen-fitted box below; they are no longer the spread's child.) */
  body.page-composed:has(.artboard.spread),
  body.page-composed:has(.journal-page) {
    --m-box-h: calc(var(--page-box) * var(--page-h) / var(--ref-width));
  }

  /* The mobile chrome pad (style.css:2094) is for a document scrolling under
     the topbar. Inside the fixed window the topbar is already accounted for
     by #book's own top offset, so this pad only pushes the first page down
     - it was 140px of the 155px the first spread sat too low. */
  body.page-composed .artboard-wrap,
  body.page-composed .series-page {
    padding-top: 0;
  }

  body.page-composed #book {
    position: fixed;
    top: var(--topbar-h);
    right: 0;
    bottom: 0;
    left: 0;
    overflow-y: scroll;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    background: var(--ground-surface);
  }

  /* view-horizontal excluded: that route's #book scrolls x, and this rule's
     extra :not() class outranked the x-axis rule below (style.css:2614), so
     the horizontal book carried snap points on an axis it never scrolls -
     no snap at all, a swipe rested between spreads. */
  body.page-composed:not(.view-long):not(.view-horizontal) #book {
    scroll-snap-type: y mandatory;
  }

  /* The rail's own top/bottom padding is what lets the FIRST and LAST box
     reach the centre of the window - without it the browser cannot scroll
     them far enough and they sit pinned to an edge. view-long is excluded
     throughout: it is one continuous document with no page seams, so it has
     nothing to centre on and nothing to land on. */
  body.page-composed:not(.view-long) .rail {
    --rail-top: var(--m-half-air);
    padding-block: var(--m-half-air);
  }

  body.page-composed:not(.view-long) .artboard.spread,
  body.page-composed:not(.view-long) .artboard.spread:last-child,
  body.page-composed:not(.view-long) .page,
  body.page-composed:not(.view-long) .page:last-child {
    margin-bottom: var(--m-page-air);
  }

  body.page-composed:not(.view-long) .artboard.spread,
  body.page-composed:not(.view-long) .page {
    scroll-snap-align: center;
  }

  /* #book owns the scroll now, so the document scroller must not snap as
     well or the two fight. */
  html:has(body.page-composed) {
    scroll-snap-type: none;
  }

  /* 2026-08-08, his call: FULL is redundant wherever the book window applies.
     The window already fills the screen below the register, so "fullscreen"
     has nothing left to reveal. Hidden at the same 1024px boundary the book
     model uses, so the two can never disagree. Desktop keeps it. */
  .topbar-nav .fs-btn,
  .fs-nav-reveal {
    display: none;
  }

  /* Framed boxes keep their authored 14px padding on desktop (where --u is
     1px) but it must scale with --u on the mobile scale model, or the box
     renders ~44px tall while its text shrinks to ~4px and the bottom edge
     sinks into the photo below. This is the mobile-only shrink; desktop
     --u == 1px keeps 14px. */
  body.page-composed .artboard-text.framed {
    padding: calc(14 * var(--u));
  }

  /* 2026-08-08 - the height override that used to live here forced every
     composed .page to the 3:2 spread ratio, which on a 375px phone is a
     220px box. Only document routes have .page elements (photographs, the
     writing index, prose, journal, legacy About) - book routes hold
     .artboard.spread - so that rule was shrinking exactly the pages that
     are meant to be read, to 40% of the height --m-page-h intends for them
     (style.css:2188: 78% of the available viewport, "a leaf, not a
     screen"). Width and centring stay; the height goes back to the token. */
  body.page-composed .page {
    width: var(--page-box);
    margin-inline: auto;
  }

  body.page-composed .journal-page {
    height: calc(var(--page-box) * var(--page-h) / var(--ref-width));
  }
  body.page-composed .journal-page {
    padding: calc(var(--margin-outer) / 2);
  }

}

/* ══════════════════════════════════════════════════════════════════════════
   Card C — horizontal book
   2026-08-16, extended 2026-08-22 (his call: every composed artboard route
   scrolls horizontally, not just the homepage). Every rule in this section
   gates on body.view-horizontal, never on a route name — one mechanism, one
   class, applied per-builder to any route whose body is a bare renderArtboard
   output (.artboard-wrap > .rail > .artboard.spread), which is what the row
   layout and flex-basis rules below assume. Currently emitted by
   buildHomepage, the series artboard branch, and the About artboard branch.
   Flow pages (Photographs, Words index, Archive, Journal) and non-spread
   fallback branches (About's intro.json path, series' no-layout.json path,
   writing's canvas-with-nav and prose paths) stay vertical — see each
   builder's own comment for why. Treatments keep their own view-long /
   view-paged mode class and are untouched here.
   ══════════════════════════════════════════════════════════════════════════ */

/* Desktop (≥1025px): #book is the horizontal viewport; the rail is one row of
   full-height centred spreads; the per-spread air delta flips to the inline
   axis against --spread-gap-x; document y-snap is off (the book owns the axis). */
@media (min-width: 1025px) {
  /* The vertical stack's end air is the rail's padding-block; in a full-height
     horizontal book that air is horizontal and centred on the sidebar-free
     content width (body has padding-left: var(--sidebar-w)). Shared by the
     rail's own padding and #book's snap padding so first/last settle centred
     right of the sidebar (verified math, Card §1.7).
     End air is always positive here: --measure's first term subtracts
     var(--ground) (6vw), so it is strictly below 100vw - var(--sidebar-w). */
  html:has(body.view-horizontal) {
    scroll-snap-type: none; /* documents outside #book must not struggle for the axis */
  }

  body.view-horizontal #book {
    height: 100vh;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    scroll-padding-inline: max(0px, calc((100vw - var(--sidebar-w) - var(--measure)) / 2));
  }

  /* .artboard-wrap { overflow: hidden } clips the row; #book is the scroller,
     so the wrap must let it out. Gated, not global. */
  body.view-horizontal .artboard-wrap {
    overflow: visible;
  }

  body.view-horizontal .artboard-wrap > .rail {
    flex-direction: row;
    width: max-content;
    height: 100vh;
    align-items: center;
    gap: var(--spread-gap-x);
    padding-top: 0;
    padding-bottom: 0;
    padding-inline: max(0px, calc((100vw - var(--sidebar-w) - var(--measure)) / 2));
  }

  /* Indivisible authored spread: fixed basis, never shrinks, snaps to centre.
     Authored geometry at .spread/.artboard (width/height/aspect) untouched. */
  body.view-horizontal .artboard.spread {
    flex: 0 0 var(--measure);
    scroll-snap-align: center;
  }

  /* Per-spread air delta, translated to the inline axis. In a row rail the
     column-only margin-bottom would separate nothing; every non-default --air
     becomes inline-end air against --spread-gap-x. Series/treatments keep the
     column rule above (@media min-width:1025px .artboard.spread):
     this override is scoped to the behaviour class. */
  body.view-horizontal .artboard.spread:not(:last-child) {
    margin-bottom: 0;
    margin-inline-end: calc((var(--air, 1) - 1) * var(--spread-gap-x));
  }

  /* 2026-09-23 (BOLD pass, Move 1), his call - the printed-edge hairline
     around each spread (.artboard.page-frame, style.css:754) reads small
     and fussy once the book fills the screen; drop it on public book
     routes at this width. Authored rules/elements INSIDE a spread (spread
     spine, frames on individual elements, proof-sheet borders) are a
     different mechanism (.artboard-el.framed, .artboard.spread-spine) and
     are untouched. Scoped to page-composed, non-treatment book routes only
     - /t/ treatments (body.page-treatment) and the studio reader
     (body.studio-reader) keep their own printed-edge decision. */
  body.page-composed.view-horizontal:not(.page-treatment):not(.studio-reader) .artboard.page-frame {
    box-shadow: none;
  }
}

/* Mobile/tablet (≤1024px) — 2026-09-23 (BOLD pass, Move 2), his call: the
   horizontal snapping pager is retired on these routes. Spreads stack
   top-to-bottom instead, each full viewport width at its authored 1440:960
   composition (same scaled elements as before — nothing here touches
   element position, only the container's flow), 10px between spreads,
   ordinary native vertical scroll: no scroll-snap, no scroll-snap-stop, no
   wheel/key/touch interception (HOMEPAGE_BOOK_JS gates its whole turn
   mechanism to desktop — see the script), pinch-zoom untouched. This
   overrides the generic body.page-composed #book (fixed, y-snap) rule
   above with a more specific selector so view-horizontal routes fall out
   of that paginated treatment entirely and back into plain document flow. */
@media (max-width: 1024px) {
  /* #book stops being its own fixed scroller (below) and the document
     becomes the scroll container again, so the page-wide
     scroll-snap-type: y mandatory (style.css:125) would otherwise start
     snapping ordinary document scroll on exactly these routes. Off here,
     same as the desktop book already turns it off for itself. */
  html:has(body.page-composed.view-horizontal) {
    scroll-snap-type: none;
  }

  body.page-composed.view-horizontal #book {
    position: static;
    overflow: visible;
    height: auto;
  }

  body.page-composed.view-horizontal .artboard-wrap {
    height: auto !important;
    overflow: visible;
    padding-top: var(--topbar-h);
  }

  body.page-composed.view-horizontal .rail {
    display: block;
    padding: 0;
  }

  body.page-composed.view-horizontal .artboard.spread,
  body.page-composed.view-horizontal .artboard.spread:last-child {
    margin-bottom: 10px;
    margin-inline: 0;
    scroll-snap-align: none;
    scroll-snap-stop: normal;
  }

  body.page-composed.view-horizontal .artboard.spread:last-child {
    margin-bottom: 0;
  }
}

/* ── Card D → 2026-09-23 (BOLD pass, Move 1) — edge-zone book turn control ───
   The bottom-centre ← → pager sat on top of the enlarged book and read as a
   UI bar interrupting the page. Replaced with two quiet full-height zones at
   the left/right of the book area (right of the sidebar): a small mono arrow
   in var(--muted) that only appears on hover/focus, the rest of the zone
   invisible but keyboard-focusable and clickable. Desktop only
   (≥1025px) — HOMEPAGE_BOOK_JS only builds these on the desktop book;
   mobile/tablet has no turn control at all (Move 2). The visually-hidden
   status span keeps the SPREAD N/total live-region announcement working for
   assistive tech without a visible bar. */
@media (min-width: 1025px) {
  body.view-horizontal .book-edge {
    position: fixed;
    top: 0;
    bottom: 0;
    width: min(96px, 6vw);
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    cursor: pointer;
    color: var(--muted);
    font-family: var(--mono);
    font-size: 15px;
    letter-spacing: .14em;
    z-index: 90;
    opacity: 0;
    transition: opacity .15s ease, color .15s ease;
  }
  body.view-horizontal .book-edge:hover,
  body.view-horizontal .book-edge:focus-visible {
    opacity: 1;
  }
  body.view-horizontal .book-edge:focus-visible {
    outline: 1px solid var(--text);
    outline-offset: -4px;
    color: var(--text);
  }
  body.view-horizontal .book-edge[hidden] { display: none; }
  body.view-horizontal .book-edge-prev {
    left: var(--sidebar-w);
  }
  body.view-horizontal .book-edge-next {
    right: 0;
  }
  body.view-horizontal .book-pager-status {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
}

/* studio reader */
body.studio-reader {
  --topbar-h: 0px;
  padding-left: 0;
}

body.studio-reader #book { background: var(--ground-surface); }

body.studio-reader .reader-mast {
  width: var(--measure);
  max-width: 100%;
  margin-inline: auto;
  padding-block: calc(var(--spread-gap) * .7);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}

body.studio-reader .reader-title {
  min-width: 0;
  font-family: var(--serif);
  font-size: clamp(1.2rem, 2vw, 1.7rem);
  font-weight: 400;
  line-height: 1.15;
}

body.studio-reader .reader-controls {
  display: flex;
  flex: 0 0 auto;
  align-items: baseline;
  gap: 1rem;
  font-family: var(--mono);
  font-size: .7rem;
  color: var(--muted);
}

body.studio-reader .reader-back:hover,
body.studio-reader .reader-fs-btn:hover { color: var(--text); }

body.studio-reader .reader-fs-btn {
  border: 0;
  padding: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

body.studio-reader .reader-rule {
  width: var(--measure);
  max-width: 100%;
  height: 1px;
  margin-inline: auto;
  background: var(--line);
}

body.studio-reader.page-treatment.view-long .rail { background: var(--bg); }
body.studio-reader.page-treatment.view-long .rail::before { display: none; }

body.studio-reader.reader-fs .reader-mast,
body.studio-reader.reader-fs .reader-rule { display: none; }

body.studio-reader.reader-fs::after {
  content: '⤾';
  position: fixed;
  z-index: 400;
  top: .75rem;
  right: .9rem;
  color: var(--muted);
  font: 1rem/1 var(--mono);
  pointer-events: none;
}

@media (min-width: 1025px) {
  body.studio-reader { --sidebar-w: 0px; }
}

@media (max-width: 1024px) {
  body.studio-reader #book { top: 0; }
  body.studio-reader .reader-mast { padding-block: 1.25rem; }
  body.studio-reader .reader-title { font-size: 1.125rem; }
}

/* ── apparatus volumes (2026-08-22) ───────────────────────────────────────
   Photographs, Words and Archive are generated volumes. They share one
   capped width, grow from their contents and use the document scroller. */
:root {
  --apparatus-max-width: 1780px;
  --apparatus-rule-alpha: .44;
  --apparatus-words-rule-alpha: .40;
  --volume-page-margin: 44px;
  --volume-datum: calc(var(--spread-gap) + 3px);
  --ledger-row-padding: 22px;
  --words-cell-padding: 44px;
}

/* The generated volume pages are not members of the fixed book composer.
   Keep this override route-gated so authored spreads retain their geometry. */
html:has(body.page-photographs),
html:has(body.page-archive) {
  scroll-snap-type: none;
}

/* The volumes carry no printed edge: they are the sheet, not a page with a
   frame around it. --ground-surface is collapsed to --bg site-wide at :root,
   so nothing route-gated is needed here any more. */

body.page-photographs #book,
body.page-archive #book {
  position: static !important;
  inset: auto !important;
  width: auto;
  height: auto !important;
  min-height: 0;
  overflow: visible !important;
  overscroll-behavior: auto;
  scroll-snap-type: none !important;
  background: transparent;
}

body.page-photographs .artboard-wrap,
body.page-archive .artboard-wrap {
  width: 100%;
  height: auto !important;
  min-height: 0;
  overflow: visible;
  padding: 0 !important;
  background: transparent;
}

body.page-photographs .rail,
body.page-archive .rail {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  width: 100%;
  height: auto;
  min-height: 0;
  gap: 0;
  padding: 0 !important;
}

body.page-photographs .photographs-page,
body.page-archive .archive-page {
  position: relative;
  width: 100%;
  max-width: none;
  min-width: 0;
  height: auto !important;
  min-height: 0 !important;
  max-height: none;
  aspect-ratio: auto;
  overflow: visible !important;
  scroll-snap-align: none !important;
  margin: 0 !important;
  border: 0;
  background: transparent;
}

/* Page frame (.page::after, style.css:652-658) needs the box to start at
   the shared datum. Beats the margin: 0 !important two rules up. */
body.page-archive .archive-page {
  margin-top: var(--volume-datum) !important;
}

body.page-photographs .photographs-page::after {
  display: none;
}

body.page-photographs .photo-grid {
  width: min(calc(100% - (2 * var(--volume-page-margin))), var(--apparatus-max-width));
  min-width: 0;
  max-width: none;
  margin-inline: auto;
  overflow: visible;
}

body.page-photographs .photo-row {
  width: 100%;
  min-width: 0;
  column-gap: var(--proof-sheet-gutter-x, var(--proof-sheet-gutter));
}

body.page-photographs .photo-cell {
  height: auto;
  min-width: 0;
}

body.page-photographs .photo-cell img {
  width: 100%;
  height: auto;
}

body.page-photographs .photo-num {
  font-family: var(--mono);
}

body.page-archive .archive-page {
  overflow-x: auto !important;
}

body.page-photographs .proof-sheet {
  margin-top: var(--volume-datum);
}

/* 2026-08-25 - the sheet takes the full ground on desktop. The :root
   --measure caps width by viewport height so a 3:2 spread fits the screen;
   the proof sheet is not a spread and scrolls, so that cap left the bottom
   of a large display empty. Same token, this route drops the height term. */
@media (min-width: 1025px) {
  body.page-photographs {
    --measure: calc(100vw - var(--sidebar-w) - var(--ground));
  }

  /* The 1780px apparatus cap is a text measure. Words and Archive keep it;
     the sheet is images and takes the full ground. */
  body.page-photographs .photo-grid {
    width: calc(100% - (2 * var(--volume-page-margin)));
  }
}

/* Two solves emitted at build time (build.js renderProofSheet) - desktop
   shown by default, mobile swapped in under the breakpoint below. No
   runtime re-solve; this is a display toggle only. */
body.page-photographs .proof-sheet[data-proof-scale="mobile"] {
  display: none;
}

body.page-photographs .photographs-page,
body.page-archive .archive-page {
  padding: 0 0 var(--volume-page-margin);
}

@media (max-width: 1024px) {
  :root {
    --volume-datum-mobile: calc(var(--topbar-h) + 3px);
  }

  body.page-photographs .proof-sheet {
    margin-top: var(--volume-datum-mobile);
  }

  /* Mobile solve swapped in - build.js renderProofSheet emits both grids,
     this is a display toggle only, no runtime re-solve. */
  body.page-photographs .proof-sheet[data-proof-scale="desktop"] {
    display: none;
  }

  body.page-photographs .proof-sheet[data-proof-scale="mobile"] {
    display: flex;
  }

  body.page-photographs .photo-grid {
    width: calc(100% - (2 * var(--m-page-pad)));
  }

  body.page-archive .archive-page {
    margin-top: var(--volume-datum-mobile) !important;
  }

  /* Item 2 (mobile pass, 2026-08-22): Words and Archive keep the printed
     edge on mobile. The generic .page::after { display:none } kill (line
     ~1203) and the volume-route border:0 (line ~2855) both switch the frame
     off for every flow route; un-suppress it here for these two only.
     Photographs stays suppressed - its cell hairlines are its edge
     treatment (spec: volume-surfaces-and-datum.md #2). The route already
     carries the .artboard-wrap gutter inset (--m-page-gutter, 6vw), so the
     border lands inside that inset rather than at the screen edge - no new
     inset needed. */
  body.page-archive .archive-page::after {
    display: block;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   2026-10-08 - the long horizontal artboard (body.view-strip)
   Spec: specs/2026-10-08-horizontal-artboard-build.md. Home and series pages
   with a -h file render ONE continuous artboard (build.js renderLongArtboard):
   .artboard-wrap-long > .rail-long > .artboard-long, 960 reference px tall,
   --run reference px long, every element placed in calc(N * var(--u)).
   On screen the artboard is the viewport height minus Evolved's margins
   (--strip-inset, 28px desktop / 10px phone, geometry.css), so --u is that
   height over 960. #book is the x scroller at every width: no snap, no
   vertical overflow. Native swipe and pinch zoom on phones are untouched;
   the wheel/keys live in build.js STRIP_JS. Every rule here is gated on
   view-strip, so the per-spread book (/unmoored/, treatments, About) is
   unchanged.
   ══════════════════════════════════════════════════════════════════════════ */
html:has(body.view-strip) {
  scroll-snap-type: none;
  overflow: hidden;
}
body.page-composed.view-strip {
  --strip-h: calc(100vh - 2 * var(--strip-inset));
  --u: calc(var(--strip-h) / 960);
  height: 100vh;
  overflow: hidden;
}
body.page-composed.view-strip #book {
  position: fixed;
  top: 0;
  bottom: 0;
  left: var(--sidebar-w);
  right: 0;
  height: auto;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: none !important;
  scroll-padding: 0;
  background: var(--ground-surface);
}
body.page-composed.view-strip .artboard-wrap.artboard-wrap-long {
  width: max-content;
  height: auto !important;
  padding: 0 !important;
  overflow: visible;
  background: none;
}
/* 2026-10-09 - desktop: the board scale is the smaller of the height fit
   and a width fit, so every frame can be seen whole and the first frame is
   whole in the first viewport. --widest (ref px, build.js renderLongArtboard)
   is the larger of the page's widest frame (image or pair) and the first
   frame's right edge. The board keeps its top inset; when the width fit wins
   it is simply shorter. Phone sizing (below) is untouched. */
@media (min-width: 1025px) {
  body.page-composed.view-strip .artboard-wrap.artboard-wrap-long {
    --u: min(
      calc((100vh - 2 * var(--strip-inset)) / 960),
      calc((100vw - var(--sidebar-w) - 2 * var(--strip-inset)) / var(--widest, 960))
    );
  }
}
body.page-composed.view-strip .rail.rail-long {
  display: block;
  width: max-content;
  padding: var(--strip-inset) !important;
  gap: 0;
}
.artboard.artboard-long {
  position: relative;
  width: calc(var(--run) * var(--u));
  height: calc(960 * var(--u));
  overflow: hidden;
  background: var(--page-surface);
  box-shadow: none;
}
.artboard-long > .artboard-weather {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
/* Spread-shaped weather (a radial vignette, a centre-gutter shadow) would
   smear across a run tens of screens long; the grain tiles at 1:1. */
.artboard-long .aw-vignette,
.artboard-long .aw-gutter { display: none; }
.artboard-long > .artboard-el,
.artboard-long > .artboard-link { z-index: 1; }
/* Frames on the run: the whole frame at its own aspect, never cropped. */
.artboard-long .artboard-el img { cursor: zoom-in; }
.artboard-long .artboard-el a img { cursor: pointer; }
.artboard-long .artboard-text.framed { padding: calc(14 * var(--u)); }
.artboard-long .artboard-text-index-entry { gap: calc(16 * var(--u)); }
.artboard-long .artboard-link:hover .artboard-text-title,
.artboard-long .artboard-link:focus-visible .artboard-text-title { text-decoration: underline; text-underline-offset: .18em; text-decoration-thickness: 1px; }
.artboard-long .artboard-link:hover .artboard-text-index-entry .ix-name { text-decoration: underline; text-underline-offset: .22em; }

/* Phone and tablet: the same long artboard, sideways, under the one-row
   register, bound by height. svh, so the run never changes height while
   the browser's toolbars move. */
@media (max-width: 1024px) {
  body.page-composed.view-strip {
    --strip-h: calc(100vh - var(--topbar-h) - 2 * var(--strip-inset-phone));
    --strip-h: calc(100svh - var(--topbar-h) - 2 * var(--strip-inset-phone));
    --u: calc(var(--strip-h) / 960);
    height: auto;
  }
  body.page-composed.view-strip #book {
    top: var(--topbar-h);
    left: 0;
    -webkit-overflow-scrolling: touch;
    touch-action: pan-x pinch-zoom;
  }
  body.page-composed.view-strip .rail.rail-long {
    padding: var(--strip-inset-phone) !important;
  }
  /* Six chapters in one row (Evolved's phone register). */
  .topbar-nav a { font-size: 10.5px; letter-spacing: .05em; }
}
@media (max-width: 360px) {
  .topbar-nav a { font-size: 9.5px; letter-spacing: .02em; }
}

/* ══════════════════════════════════════════════════════════════════════════
   2026-10-09 - Words as a horizontal reading strip (body.view-strip).
   Markup: build.js buildWriting. Same long board as home and series: 960 ref
   px tall, side-scrolling. Each piece is a mono label line over serif text in
   CSS columns at board height. Text never clips: build.js measures every
   block at the column width and sizes each piece to the columns it needs, so
   font, size, leading and block gap below must match build.js (WORDS_*).
   Nothing here sets overflow: hidden.
   ══════════════════════════════════════════════════════════════════════════ */
.artboard-long .words-piece {
  position: absolute;
  top: 0;
  height: calc(960 * var(--u));
  z-index: 1;
}
.artboard-long .words-label {
  position: absolute;
  left: 0;
  top: calc(40 * var(--u));
  width: 100%;
  display: flex;
  align-items: baseline;
  gap: calc(12 * var(--u));
  font-family: var(--mono);
  font-size: calc(13 * var(--u));
  letter-spacing: .08em;
  text-transform: lowercase;
  color: var(--apparatus);
}
.artboard-long .words-label .words-title {
  color: var(--text);
  text-decoration: none;
}
.artboard-long .words-label .words-title:hover,
.artboard-long .words-label .words-title:focus-visible {
  text-decoration: underline;
  text-underline-offset: .22em;
  text-decoration-thickness: 1px;
}
.artboard-long .words-label .words-year {
  margin-left: auto;
  color: var(--muted);
}
.artboard-long .words-text {
  position: absolute;
  left: 0;
  top: calc(120 * var(--u));
  width: 100%;
  height: calc(800 * var(--u));
  column-gap: calc(40 * var(--u));
  column-fill: auto;
  font-family: var(--serif);
  font-size: calc(17 * var(--u));
  line-height: 1.65;
  color: var(--text);
}
.artboard-long .words-block {
  white-space: pre-wrap;
  break-inside: avoid;
  orphans: 1;
  widows: 1;
}
.artboard-long .words-block--gap {
  margin-top: calc(12 * var(--u));
}

/* ══════════════════════════════════════════════════════════════════════════
   2026-10-09 - One-screen boards (CMS pass P1, specs/cms-pass/01-about-spec.md).
   Markup: build.js renderLongArtboard + longBoardBodyClass; data: about.json
   "fitScreen": true, "phone": "stack". Desktop needs no rule here: a
   fitScreen board sets --widest to its whole run, so the view-strip scale
   above shows all of it at the same top, left and inset as every other
   board. At <=1024px a "stack" board is one page: its elements read top to
   bottom in board order (build.js ranks by top, then left) at reading size,
   scrolling vertically. Frames stay whole at their own aspect, never
   cropped and never taller than the screen.
   ══════════════════════════════════════════════════════════════════════════ */
.artboard-long .artboard-text a:hover,
.artboard-long .artboard-text a:focus-visible {
  text-decoration: underline;
  text-underline-offset: .22em;
  text-decoration-thickness: 1px;
}
@media (max-width: 1024px) {
  body.page-composed.view-strip.board-stack {
    --u: min(calc(100vw / 480), 1px);
  }
  body.page-composed.view-strip.board-stack #book {
    overflow-x: hidden;
    overflow-y: auto;
    touch-action: pan-y pinch-zoom;
  }
  body.page-composed.view-strip.board-stack .artboard-wrap.artboard-wrap-long,
  body.page-composed.view-strip.board-stack .rail.rail-long {
    width: 100%;
  }
  body.page-composed.view-strip.board-stack .rail.rail-long {
    padding: var(--strip-inset-phone) 0 !important;
  }
  body.page-composed.view-strip.board-stack .artboard.artboard-long {
    width: auto;
    height: auto;
    min-height: calc(100svh - var(--topbar-h) - 2 * var(--strip-inset-phone));
    overflow: visible;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    gap: calc(28 * var(--u));
    padding: calc(40 * var(--u)) calc(44 * var(--u)) calc(44 * var(--u)) 8.75%;
  }
  body.page-composed.view-strip.board-stack .artboard-long > .artboard-el,
  body.page-composed.view-strip.board-stack .artboard-long > .artboard-link {
    position: relative !important;
    left: auto !important;
    top: auto !important;
    width: auto !important;
    order: var(--rank);
  }
  /* Frames: whole, uncropped (width and height both auto under max caps keep
     the aspect), never taller than one screen. */
  body.page-composed.view-strip.board-stack .artboard-long > .artboard-el img {
    width: auto;
    max-width: 100%;
    height: auto;
    max-height: calc(100svh - var(--topbar-h) - 2 * var(--strip-inset-phone) - 84 * var(--u));
  }
  /* The gutter rule becomes the page's left margin line, as on the phone
     About before this pass. */
  body.page-composed.view-strip.board-stack .artboard-long > .vertical-rule {
    position: absolute !important;
    left: 6.389% !important;
    top: 0 !important;
    height: 100% !important;
    width: 0 !important;
  }
  body.page-composed.view-strip.board-stack .artboard-long > .artboard-rule:not(.vertical-rule) {
    width: 30% !important;
    align-self: flex-end;
    margin-top: auto;
    margin-bottom: calc(-12 * var(--u));
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   2026-10-09 - About as one composed page (no view-horizontal, no
   view-strip). The authored spread (about.json) is fitted whole to the
   screen by the existing --measure / --u (height or width, whichever is
   smaller) and centred in the window; nothing scrolls sideways. On a phone
   it is one page that scrolls vertically: the spread's own text would set
   at about 5px there, so under 900px the same elements read top to bottom
   at reading size (authored order, authored words, authored sizes scaled by
   the page unit) instead of at their desktop coordinates.
   ══════════════════════════════════════════════════════════════════════════ */

@media (min-width: 1025px) {
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard-wrap {
    min-height: 100vh;
    display: flex;
    align-items: center;
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .rail {
    width: 100%;
  }
}

@media (max-width: 1024px) {
  body.page-about:not(.view-horizontal):not(.view-strip) #book {
    scroll-snap-type: none !important;
    overflow-x: hidden;
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard-wrap {
    min-height: 100%;
    display: flex;
    align-items: center;
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .rail {
    width: 100%;
    padding-block: var(--strip-inset-phone);
  }
}

@media (max-width: 900px) {
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard.spread {
    --u: calc(100vw / 480);
    height: auto !important;
    aspect-ratio: auto !important;
    min-height: calc(100svh - var(--topbar-h) - 2 * var(--strip-inset-phone));
    display: flex;
    flex-direction: column;
    gap: calc(28 * var(--u));
    padding: calc(56 * var(--u)) calc(44 * var(--u)) calc(44 * var(--u)) 8.75%;
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard.spread > .artboard-el {
    position: relative !important;
    left: auto !important;
    top: auto !important;
    width: auto !important;
    z-index: 1;
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard.spread > .vertical-rule {
    position: absolute !important;
    left: 6.389% !important;
    top: 0 !important;
    height: 100% !important;
    width: 0 !important;
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard.spread > .artboard-rule:not(.vertical-rule) {
    width: 30% !important;
    align-self: flex-end;
    margin-top: auto;
    margin-bottom: calc(-12 * var(--u));
  }
  body.page-about:not(.view-horizontal):not(.view-strip) .artboard-text-meta {
    font-size: 11px;
  }

}

/* ══════════════════════════════════════════════════════════════════════════
   2026-10-09 (his call) - Archive as a folder. Same ledger, sleeker: a tab
   with the count and year span over the sheet. Look kept from the old ledger
   (his call, same day): mono, lowercase, 12px, page ground, .44 rules. Markup: build.js buildArchive (.af-*). The old
   .series-ledger rules above no longer match anything.
   ══════════════════════════════════════════════════════════════════════════ */
body.page-archive .af {
  --af-cols: 56px minmax(0, 2.1fr) minmax(0, 1.3fr) minmax(0, 1.5fr) 56px;
  --af-pad: 32px;
  margin: var(--volume-datum) var(--volume-page-margin) 0;
}
/* the folder is the edge now; the page's printed frame would double it */
body.page-archive .archive-page::after { display: none !important; }
body.page-archive .af-tab {
  display: inline-flex;
  align-items: baseline;
  gap: 18px;
  padding: 11px 22px 10px var(--af-pad);
  background: var(--bg);
  border: 1px solid rgba(212, 212, 208, var(--apparatus-rule-alpha));
  border-bottom: 0;
  border-radius: 3px 3px 0 0;
  position: relative;
  top: 1px; /* sits on the sheet's top edge, so tab and sheet read as one */
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .04em;
  text-transform: lowercase;
}
body.page-archive .af-tab-label { color: var(--text); }
body.page-archive .af-tab-count { color: var(--muted); }
body.page-archive .af-sheet {
  background: var(--bg);
  border: 1px solid rgba(212, 212, 208, var(--apparatus-rule-alpha));
  border-radius: 0 3px 3px 3px;
  padding: 8px 0 18px;
}
body.page-archive .af-cols,
body.page-archive .af-row {
  display: grid;
  grid-template-columns: var(--af-cols);
  column-gap: 28px;
  align-items: baseline;
  padding-inline: var(--af-pad);
}
body.page-archive .af-cols {
  padding-block: 18px 12px;
  border-bottom: 1px solid rgba(212, 212, 208, var(--apparatus-rule-alpha));
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .04em;
  text-transform: lowercase;
  color: var(--text);
}
body.page-archive .af-cols span:last-child,
body.page-archive .af-year { text-align: right; }
body.page-archive .af-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
body.page-archive .af-list li + li .af-row { border-top: 1px solid rgba(212, 212, 208, var(--apparatus-rule-alpha)); }
body.page-archive .af-row {
  padding-block: var(--ledger-row-padding);
  color: var(--text);
  text-decoration: none;
  transition: background .15s ease;
}
body.page-archive .af-row:hover,
body.page-archive .af-row:focus-visible {
  background: rgba(212, 212, 208, .035);
  outline: none;
}
body.page-archive .af-title {
  font-family: var(--mono);
  font-size: 13px;
  line-height: 1.3;
  letter-spacing: .04em;
  text-transform: lowercase;
  color: var(--text);
  width: max-content;
  max-width: 100%;
  border-bottom: 1px solid transparent;
  transition: border-color .15s ease;
}
body.page-archive .af-row:hover .af-title,
body.page-archive .af-row:focus-visible .af-title { border-bottom-color: var(--text); }
body.page-archive .af-no,
body.page-archive .af-client,
body.page-archive .af-role,
body.page-archive .af-year {
  font-family: var(--mono);
  font-size: 12px;
  line-height: 1.3;
  letter-spacing: .04em;
  text-transform: lowercase;
  color: var(--muted);
  overflow-wrap: anywhere;
}
body.page-archive .af-row:hover .af-no { color: var(--text); }

@media (max-width: 1024px) {
  body.page-archive .af {
    --af-pad: 16px;
    margin: 0 auto;
    width: calc(100% - (2 * var(--m-page-pad)));
  }
  body.page-archive .af-cols { display: none; }
  body.page-archive .af-sheet { padding-top: 0; }
  /* client keeps its own width per row; role takes the rest, right-aligned,
     and wraps rather than running into the client */
  body.page-archive .af-row {
    grid-template-columns: 30px auto minmax(0, 1fr) auto;
    grid-template-areas: "no title title year" ". client role role";
    column-gap: 12px;
    row-gap: 4px;
  }
  body.page-archive .af-no { grid-area: no; }
  body.page-archive .af-title { grid-area: title; }
  body.page-archive .af-year { grid-area: year; }
  body.page-archive .af-client { grid-area: client; }
  body.page-archive .af-role { grid-area: role; text-align: right; overflow-wrap: normal; }
}

/* ══════════════════════════════════════════════════════════════════════════
   2026-10-09 (his call: "no photo", "just make it a full page", "scale the
   text up"). About on every screen is one full page that reads top to bottom:
   the same stack as the phone, at a larger page unit. Elements, words and
   order stay in about.json (CMS-editable); only the board box is dropped.
   ══════════════════════════════════════════════════════════════════════════ */
body.page-about.board-stack {
  --u: clamp(1px, calc(100vw / 680), 2.6px);
}
body.page-about.board-stack #book {
  overflow-x: hidden;
  overflow-y: auto;
  touch-action: pan-y pinch-zoom;
}
body.page-about.board-stack .artboard-wrap.artboard-wrap-long,
body.page-about.board-stack .rail.rail-long {
  width: 100%;
}
body.page-about.board-stack .rail.rail-long {
  padding: 0 !important;
}
body.page-about.board-stack .artboard.artboard-long {
  width: auto;
  height: auto;
  min-height: calc(100svh - var(--topbar-h, 0px));
  overflow: visible;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: calc(28 * var(--u));
  padding: calc(56 * var(--u)) calc(56 * var(--u)) calc(56 * var(--u)) 8.75%;
  box-shadow: none;
  background: transparent;
}
body.page-about.board-stack .artboard-long > .artboard-el,
body.page-about.board-stack .artboard-long > .artboard-link {
  position: relative !important;
  left: auto !important;
  top: auto !important;
  width: auto !important;
  max-width: calc(640 * var(--u));
  order: var(--rank);
}
body.page-about.board-stack .artboard-long > .vertical-rule {
  position: absolute !important;
  left: 6.389% !important;
  top: 0 !important;
  height: 100% !important;
  width: 0 !important;
}
body.page-about.board-stack .artboard-long > .artboard-rule:not(.vertical-rule) {
  width: calc(187 * var(--u)) !important;
  max-width: 30%;
  margin-top: auto;
  margin-bottom: calc(-12 * var(--u));
}
