/* ============================================================
   MEROVA — site styles
   Layer order (z): sky canvas (0) < route/ship (1) < content (2)
   < header (10). The sky is fixed; everything else scrolls.
   Section contract: each major block is a <section class="port">
   with a [data-port] side hint read by voyage.js for the route.
   ============================================================ */

/* ---------- fonts (self-hosted, SIL OFL) ---------- */
@font-face {
  font-family: "Cinzel"; font-style: normal; font-weight: 400; font-display: swap;
  src: url("../assets/fonts/cinzel-v26-latin-regular.woff2") format("woff2");
}
@font-face {
  font-family: "Cinzel"; font-style: normal; font-weight: 500; font-display: swap;
  src: url("../assets/fonts/cinzel-v26-latin-500.woff2") format("woff2");
}
@font-face {
  font-family: "Cinzel"; font-style: normal; font-weight: 600; font-display: swap;
  src: url("../assets/fonts/cinzel-v26-latin-600.woff2") format("woff2");
}
@font-face {
  font-family: "Cinzel"; font-style: normal; font-weight: 700; font-display: swap;
  src: url("../assets/fonts/cinzel-v26-latin-700.woff2") format("woff2");
}
@font-face {
  font-family: "EB Garamond"; font-style: normal; font-weight: 400; font-display: swap;
  src: url("../assets/fonts/eb-garamond-v33-latin-regular.woff2") format("woff2");
}
@font-face {
  font-family: "EB Garamond"; font-style: italic; font-weight: 400; font-display: swap;
  src: url("../assets/fonts/eb-garamond-v33-latin-italic.woff2") format("woff2");
}
@font-face {
  font-family: "EB Garamond"; font-style: normal; font-weight: 500; font-display: swap;
  src: url("../assets/fonts/eb-garamond-v33-latin-500.woff2") format("woff2");
}
@font-face {
  font-family: "EB Garamond"; font-style: normal; font-weight: 600; font-display: swap;
  src: url("../assets/fonts/eb-garamond-v33-latin-600.woff2") format("woff2");
}

/* ---------- reset & base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
/* Reserved so the theater's scroll lock costs nothing. Locking scroll
   normally removes the scrollbar and reflows the page ~15px sideways under
   the reader — with the gutter always reserved, `overflow: hidden` changes
   the width of nothing. It is free here because this page is seven screens
   tall and has never once been short enough to lack a scrollbar. */
html { scrollbar-gutter: stable; }
body {
  margin: 0;
  background:
    radial-gradient(ellipse 90% 55% at 78% -6%, var(--glow-blue), transparent 60%),
    linear-gradient(168deg, #0D1524 0%, var(--ink) 42%, var(--ink-deep) 100%);
  background-color: var(--ink);
  color: var(--text);
  font-family: var(--f-body);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}
img { max-width: 100%; height: auto; display: block; }
a { color: var(--mist); text-underline-offset: 3px; transition: color .25s ease; }
a:hover { color: var(--gold-bright); }
:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; border-radius: 2px; }
::selection { background: color-mix(in srgb, var(--gold) 35%, transparent); color: var(--starlight); }

h1, h2, h3 { font-family: var(--f-display); color: var(--starlight); font-weight: 600; line-height: 1.12; margin: 0 0 .6em; }
p { margin: 0 0 1em; }
.skip-link {
  position: absolute; left: -9999px; top: 12px; z-index: 100;
  background: var(--field); color: var(--starlight); padding: 10px 18px; border-radius: var(--radius-sm);
}
.skip-link:focus { left: 12px; }
/* Visually hidden but read aloud and indexed. Distinct from .skip-link,
   which is hidden only until it takes focus — .vh is for content that is
   never meant to become visible, like the h1 the hero's film carries in
   pictures instead of in text. */
.vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
  margin: -1px; padding: 0; border: 0;
  /* Firefox logs a warning for every `<h1>` inside a sectioning element
     that has no explicit font-size, because the UA's own size for it is
     being removed from the platform. The hero's h1 is this class and is
     never drawn, so the size is arbitrary — but stating it silences a
     console warning that was firing on every load of the homepage in one
     of the four engines, and console noise is where real errors hide. */
  font-size: 1rem;
}

/* ---------- fixed sky & voyage layers ---------- */
.sky {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
}
.sky canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.sky .nebula {
  position: absolute; inset: 0;
  background:
    radial-gradient(ellipse 42% 30% at 16% 34%, color-mix(in srgb, var(--mist) 7%, transparent), transparent 70%),
    radial-gradient(ellipse 50% 34% at 86% 62%, color-mix(in srgb, var(--gold) 4%, transparent), transparent 70%),
    radial-gradient(ellipse 60% 40% at 50% 108%, color-mix(in srgb, var(--mist) 5%, transparent), transparent 72%);
}
#voyage {
  position: absolute; top: 0; left: 0; width: 100%; z-index: 1; pointer-events: none;
  overflow: visible;
}
#voyage .route-path {
  fill: none; stroke: var(--mist); stroke-opacity: .34;
  stroke-width: 1.6; stroke-dasharray: 2 9; stroke-linecap: round;
}
#voyage .route-glow {
  fill: none; stroke: var(--mist); stroke-opacity: .08; stroke-width: 6;
  stroke-dasharray: 2 9;
}
#voyage .route-wake {
  fill: none; stroke: var(--gold); stroke-opacity: .55; stroke-width: 1.8;
  stroke-dasharray: 2 9; stroke-linecap: round;
}
#voyage .port-node { fill: var(--ink); stroke: var(--mist); stroke-opacity: .55; stroke-width: 1.4; transition: all .6s ease; }
#voyage .port-node.lit { fill: var(--gold); stroke: var(--gold-bright); stroke-opacity: 1; filter: drop-shadow(0 0 6px color-mix(in srgb, var(--gold) 70%, transparent)); }
#ship {
  position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;
  width: 132px; height: auto; will-change: transform;
  filter: drop-shadow(0 10px 18px rgba(3, 8, 18, .8));
}
@media (max-width: 820px) {
  #ship { width: 88px; }
  #voyage .route-path, #voyage .route-glow { stroke-opacity: .22; }
  #voyage .route-wake { stroke-opacity: .4; }
}

/* ---------- header ---------- */
/* The scrim used to arrive only on scroll, which was safe when the top of
   every page was dark. It is not any more: the hero is a film that runs from
   amber cloud to a pale gold title card, and MEASURED over that card the nav
   sat at 2.37:1 against --mist — a fail. So the header carries its ground at
   all times now and `.scrolled` only adds the edge and the lift. */
/* ★★ THE GROUND AND THE BLUR LIVE ON A ::before, NOT ON THE HEADER ITSELF
   (2026-08-14), and that is not tidiness — it is the only way the mobile
   drawer can work. `backdrop-filter` with any value other than `none`
   creates a CONTAINING BLOCK for fixed-position descendants, exactly as a
   transform does. `.site-nav` is a `position: fixed; inset: 0` child of this
   element, so a blurred header meant the full-screen menu resolved against
   the HEADER's box instead of the viewport: measured 375x112 at the top of
   the screen, with four of its six links laid out above y=0 and unreachable.
   That was true on every phone, in all four engines, from the day the blur
   was added — the drawer had simply never been opened in a test.

   Moving the paint to a pseudo-element leaves the header itself unfiltered,
   so it is no longer anybody's containing block, and the bar looks exactly
   the same. Same trick as the chart plate: the effect goes on a layer, not
   on the box that has to stay geometrically honest. */
.site-header {
  position: fixed; top: 0; left: 0; right: 0; z-index: 10;
  transition: box-shadow .4s ease;
}
.site-header::before {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: linear-gradient(180deg, color-mix(in srgb, var(--ink) 88%, transparent), color-mix(in srgb, var(--ink) 66%, transparent));
  -webkit-backdrop-filter: blur(9px);
          backdrop-filter: blur(9px);
  transition: background .4s ease, backdrop-filter .4s ease;
}
.site-header.scrolled::before {
  background: linear-gradient(180deg, color-mix(in srgb, var(--ink) 92%, transparent), color-mix(in srgb, var(--ink) 78%, transparent));
}
/* box-shadow stays on the element — unlike a filter it creates no
   containing block, and it has to sit outside the header's own box. */
.site-header.scrolled {
  box-shadow: 0 1px 0 var(--hairline), var(--shadow-ambient);
}

/* ★★ THE HEADER TUCKS AWAY WHEN YOU SAIL ON (2026-08-14).
   A fixed bar on a seven-screen page is 68px of the reader's screen rented
   out permanently to a menu they are not using. It now behaves the way a
   reader already expects a bar to behave: scroll DOWN and it slides up out
   of the way; scroll UP — the gesture that means "I want to go somewhere" —
   and it comes straight back, wherever on the page you are.

   TRANSFORM, not `top`, so it is a compositor job and never triggers layout
   on a scroll. The `.scrolled` ground and shadow are a separate class and a
   separate transition, so the two states compose instead of fighting.

   ★★ THE TRAP, and it is invisible until it bites: `.site-nav` is the
   MOBILE DRAWER and it is a `position: fixed` CHILD of this element. Any
   non-`none` transform makes an element the containing block for its fixed
   descendants — so a tucked header carries the full-screen drawer up off the
   top of the screen with it, and the menu opens to nothing.

   THREE THINGS TOGETHER, because one of them is not enough:
     - NO IDENTITY TRANSFORM in the resting state. `transform: translateY(0)`
       was written here first and it is not a no-op: `matrix(1,0,0,1,0,0)` is
       a transform, so the header was a containing block even when it had not
       moved a pixel. Measured drawer: 375x112 instead of 375x844. The
       transition from `none` to a translate interpolates perfectly well
       without it.
     - `transition: none` while the drawer is open, so an in-flight tuck
       cannot leave a matrix on the element for the 340ms it would take to
       resolve to `none`. A transform that is ON ITS WAY to none is still a
       transform.
     - and js/site.js refuses to add `.is-tucked` in this state at all.
   No `will-change: transform` either, for the same reason — it creates the
   containing block on its own, permanently, in exchange for a hint the
   compositor does not need for a 340ms translate on a fixed bar.

   Under reduced motion it never moves at all — a bar that slides in and out
   of frame on every scroll is precisely the motion that setting asks to be
   spared, and the page loses nothing by keeping it. See the reduced-motion
   block at the foot of this file. */
.site-header.is-tucked { transform: translateY(-100%); }
body.nav-open .site-header { transform: none; transition: none; }
body.is-theater .site-header { transform: none; }

.header-inner {
  max-width: var(--container); margin: 0 auto;
  padding: 14px calc(var(--gutter) + var(--safe-l)) 14px calc(var(--gutter) + var(--safe-r));
  display: flex; align-items: center; gap: clamp(14px, 2.4vw, 28px);
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; flex: none; }
.brand img { height: 30px; width: auto; }
/* `min-width: 0` so the nav is allowed to be the thing that gives when the
   two do not fit — without it a flex item refuses to go below its
   min-content width and the whole bar overflows the header instead. */
.site-nav { margin-left: auto; display: flex; gap: 2px; min-width: 0; }
.site-nav a {
  font-family: var(--f-display); font-weight: 600;
  /* ★ MEASURED, NOT CHOSEN (2026-08-14). At .78rem/.14em/13px-padding the
     six labels plus the mark needed 984-999px depending on the ENGINE —
     Firefox laid the same bar out 15px wider than Chromium — against a
     content box of `min(100vw, 1100px) - 80`. That is 1020px at any desktop
     width: the bar was clearing its container by about 2%, and on the 900px
     screens where it was still being shown it was not clearing it at all
     (it wrapped every label onto two lines and doubled the header's
     height). Two percent is not a margin, it is a coincidence.
     Now it scales with the viewport and stops growing at 1400, where it has
     ~140px of clear water in the widest of the four engines. */
  font-size: clamp(.7rem, .52rem + .32vw, .78rem);
  letter-spacing: clamp(.08em, .02em + .35vw, .14em);
  text-transform: uppercase; text-decoration: none; white-space: nowrap;
  color: var(--mist); padding: 9px clamp(8px, .9vw, 13px); border-radius: var(--radius-sm);
}
.site-nav a:hover { color: var(--gold-bright); }
.site-nav a.is-active { color: var(--starlight); }
.nav-toggle { display: none; }

/* ★ THE DRAWER NOW COVERS EVERYTHING UNDER 1025px, not under 821px.
   The old boundary put a six-item horizontal bar on every screen from 821
   to 1023 — the whole tablet-landscape and small-laptop band — where the
   measurement above says it has never fitted. The chart rail switches at
   the same number (it used to be 1023) so the two instruments change over
   together instead of one pixel apart. */
@media (max-width: 1024px) {
  .site-nav {
    position: fixed; inset: 0; z-index: 20; flex-direction: column; align-items: center;
    justify-content: center; gap: 10px;
    padding: calc(var(--header-h) + 16px) var(--gutter) calc(24px + var(--safe-b));
    overflow-y: auto; overscroll-behavior: contain;
    background: color-mix(in srgb, var(--ink) 96%, transparent);
    -webkit-backdrop-filter: blur(10px);
            backdrop-filter: blur(10px);
    opacity: 0; pointer-events: none; visibility: hidden;
    transition: opacity .3s ease, visibility .3s;
  }
  .site-nav.open { opacity: 1; pointer-events: auto; visibility: visible; }
  /* Full-width rows with a real 48px target, so the drawer is a list you
     tap rather than six words floating in a column. */
  .site-nav a {
    font-size: 1.05rem; letter-spacing: .14em;
    width: min(22rem, 100%); text-align: center;
    padding: 13px 18px; min-height: 48px;
    display: flex; align-items: center; justify-content: center;
  }
  .nav-toggle {
    display: grid; place-items: center; margin-left: auto; z-index: 21;
    background: none; border: 1px solid var(--hairline); border-radius: var(--radius-sm);
    color: var(--mist); width: 44px; height: 44px; cursor: pointer; flex: none;
  }
  .nav-toggle:hover { color: var(--gold-bright); border-color: var(--panel-border-hi); }
}
/* ★ THE ICON SWAP IS CSS, OFF `aria-expanded` (2026-08-14).
   It used to be two `style="display:none"` attributes in the markup, driven
   by two `.style.display =` writes in js/site.js. read.html carries a strict
   `style-src 'self'` policy with no `unsafe-inline`, which BLOCKS an inline
   style attribute outright — so on the one page with a CSP the close icon
   would have sat on top of the menu icon from first paint. Driving it from
   the attribute that already carries the state means one source of truth,
   no inline styles anywhere on the site, and two fewer lines of script. */
.nav-toggle .ico-close { display: none; }
.nav-toggle[aria-expanded="true"] .ico-menu { display: none; }
.nav-toggle[aria-expanded="true"] .ico-close { display: block; }
/* A drawer over a page that still scrolls underneath is the oldest bug in
   mobile navigation. Free of layout shift because `scrollbar-gutter: stable`
   already reserves the bar — the same trade the theater makes. */
body.nav-open { overflow: hidden; }

/* ---------- hero ---------- */
/* The copy is anchored to the TOP-LEFT corner, not centred: the ship holds
   the middle of the frame for the whole film, so a corner is the only place
   a block of text can sit without being laid across her. */
/* THE RUNWAY IS GONE. .hero was 2.4 screens tall so there was something to
   scroll while the galaxy camera descended. Nothing descends now — the film
   plays on its own clock — so the hero is one screen again and .hero-stage
   no longer has to stick to the top of it. Both elements are KEPT, and keep
   sensible heights, because voyage.js measures them for the chart line's
   first waypoint: `hero.offsetTop + stage.offsetHeight * 0.94`
   (js/voyage.js:146-148). Collapse them to nothing and the route starts in
   the wrong place. */
/* `100vh` first, `100svh` second: an engine that does not know the small
   viewport unit drops the whole declaration, and dropping the FALLBACK is
   free where dropping the only height would collapse the hero. Every
   `svh` in this file is paired the same way. */
.hero {
  position: relative; z-index: 2;
  min-height: 100vh; min-height: 100svh;
  isolation: isolate;
}
/* ★★★ THE STAGE CARRIES NO COPY (2026-08-13, rev 3). This padding was a
   RESERVATION: first clamp(215px, 24vh, 260px) to drop the tagline and
   buttons below the wordmark, then clamp(124px, 15vh, 168px) to lift them
   back into the corner once the wordmark became a short title card. Both
   were answers to the wrong question. There is nothing over the film now
   but its own title card and the cue, so there is nothing to reserve for.

   The two heights that are KEPT are load-bearing and must stay: min-height
   100svh because js/voyage.js measures `hero.offsetTop + stage.offsetHeight
   * 0.94` for the chart line's first waypoint (js/voyage.js:146-148), and
   the bottom padding because the cue is positioned against this box. The
   grid stays too — .hero-wordmark and .scroll-cue are both positioned
   against this element. */
.hero-stage {
  position: relative;
  min-height: 100vh; min-height: 100svh;
  display: grid; align-content: start;
  padding: 0 calc(var(--gutter) + var(--safe-l)) 110px calc(var(--gutter) + var(--safe-r));
}
.hero-art {
  position: absolute; inset: 0; z-index: -1; overflow: hidden;
  background: var(--ink);
}
.hero-art canvas { display: block; width: 100%; height: 100%; }
/* `cover`, so the film fills whatever shape the viewport is. That crops it
   hard in portrait — a phone keeps roughly the middle quarter of the width —
   which is affordable ONLY because the ship is composed dead centre and the
   closing card is centred too. Do not replace this footage with anything
   that puts its subject off-axis without revisiting this line. */
.hero-video, .hero-still {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: 50% 50%;
}
.hero-still[hidden] { display: none; }

/* If WebGL is missing, .no-galaxy paints a still core so the hero is a
   sky rather than an empty rectangle. The markers still work over it. */
.hero.no-galaxy .hero-art {
  background:
    radial-gradient(ellipse 34% 26% at 68% 44%, color-mix(in srgb, var(--gold) 13%, transparent), transparent 68%),
    radial-gradient(ellipse 70% 52% at 68% 44%, color-mix(in srgb, var(--mist) 10%, transparent), transparent 72%);
}
/* Melt the film into the page: a pool of shade under the corner block, and
   a floor fade.

   ★ The pool is STRONGER and SHALLOWER than the galaxy's was, and both
   changes are load-bearing. Stronger, because the brightest frame in the
   film is the closing card and it is the frame the hero RESTS on — the one
   people actually sit looking at — and over it the tagline measured 2.03:1
   against --mist, a clear fail. Shallower, because the card's own ADALASIA
   sits at roughly 55-70% of the hero's height: the pool has to be spent and
   gone before it reaches the title, or the fix for the copy becomes a stain
   across the payoff. Hence 46% height, not 86%.

   ★★ THE FLOOR FADE NEEDED THE SAME TREATMENT AND HAD NEVER HAD IT.
   It used to be a straight `transparent 72% -> var(--ink) 100%`, i.e. 50-75%
   opaque ink across the bottom of the frame — exactly where the card puts
   "BOOK ONE · THE JAY" and "available at amazon". Measured off the rendered
   page, that dimmed the film's own type to 3.73:1 and 1.89:1. It was not a
   v2 regression: the gold card measured 3.82:1 in the same place, so the
   payoff had been mud since the film became the hero. Nobody caught it
   because the contrast pass only ever checked the SITE's copy over the card,
   never the CARD's copy under the site's gradients.

   So the fade is now shaped, not linear: almost nothing (<=17%) down to 91%
   of the hero, which is where the card's lockup ends (video y 653 of 720 =
   90.7%, and `cover` is height-driven at every aspect narrower than 16:9, so
   that percentage holds), then the whole ramp to solid --ink in the last 9%.
   The bottom edge still melts — the section below is a dark starfield and a
   hard seam there would be obvious — it just does it below the words instead
   of through them.

   Knock-on, and the reason .scroll-cue::before exists below: that fade WAS
   the cue's only ground. See the note there.

   The old top band is gone: the header carries its own ground now. */
.hero-art::after {
  content: ""; position: absolute; inset: 0;
  background:
    radial-gradient(72% 46% at -4% -2%,
      color-mix(in srgb, var(--ink) 97%, transparent) 0%,
      color-mix(in srgb, var(--ink) 90%, transparent) 50%,
      color-mix(in srgb, var(--ink) 50%, transparent) 68%,
      transparent 84%),
    linear-gradient(180deg,
      transparent 74%,
      color-mix(in srgb, var(--ink) 7%, transparent) 88%,
      color-mix(in srgb, var(--ink) 14%, transparent) 91%,
      var(--ink) 100%);
}
/* ---------- the 3D wordmark over the film ----------
   z-index 1: above .hero-art (-1), below .hero-inner (2), all inside the
   hero's own `isolation: isolate`. It never takes pointer events — the
   corner copy and its two buttons are the only things in this section a
   reader can touch.

   The filter is written as one list of the same five functions every time so
   that `transition: filter` can interpolate it. site.js updates the five
   custom properties four times a second off `timeupdate`, and this 0.4s
   transition is what turns those steps into a drift — without it the colour
   visibly ticks. --wm-* defaults are the t=0 row of the table, so the first
   painted frame is already correct rather than flashing full-strength gold
   over the amber it has to survive. */
/* ★ IT HAS THE TOP STRIP TO ITSELF, and it only wants it for 2.15 seconds
   (2026-08-14: it now leaves at 2.65s, before the sky turns purple — the
   measurement and the reasoning are in js/site.js, which owns the timing).
   Centred at 640px wide on a 1440 viewport the mark spans x 400-1040;
   `.hero-copy` is 30rem inside an 1100px container, so it spans 170-650.
   The first pass put both at the same height and ADALASIA's first two
   letters landed across the tagline and the DISCOVER THE JAY button; the
   second pass pushed the copy down out of the way and left it stranded in
   mid-frame. The answer was never geometry, it was TIME: the card plays,
   the card leaves, and the copy arrives in the space it vacated. Neither
   one has to give anything up.

   THE SETTLE. It enters at scale 1.028 and lands on 1.0 over 1.5s — the
   push-in that makes a title card read as arriving rather than appearing —
   then drifts back out as it fades. The base rule carries the EXIT
   transition and `.is-lit` carries the ENTRANCE, which is what lets the two
   have different durations off a single class toggle: 0.8s in, 0.5s out. A
   card that leaves slower than it came reads as reluctant.

   The transform has to keep `translateX(-50%)` in it — that is the
   centring, not decoration — so the scale rides in a custom property and
   the transform string itself never changes shape. */
.hero-wordmark {
  position: absolute; z-index: 1; left: 50%;
  top: clamp(84px, 11vh, 128px);
  transform: translateX(-50%) scale(var(--wm-k, 1.028));
  width: min(52vw, 640px);
  pointer-events: none;
  opacity: 0;
  transition: opacity .5s ease, transform .5s ease;
}
.hero-wordmark[hidden] { display: none; }
.hero-wordmark.is-lit {
  opacity: 1; --wm-k: 1;
  transition: opacity .8s ease, transform 1.5s var(--ease-out-expo);
}
.hero-wordmark img {
  display: block; width: 100%; height: auto;
  filter:
    brightness(var(--wm-b, .84))
    saturate(var(--wm-s, 1))
    hue-rotate(var(--wm-h, -6deg))
    drop-shadow(0 2px 9px rgba(6, 10, 20, var(--wm-ink, .6)))
    drop-shadow(0 0 26px rgba(232, 179, 106, var(--wm-glow, .04)));
  transition: filter .4s linear;
}

/* ---------- the sill ----------
   The imprint's one line, in the seam between the film and the first port.

   It is a SEAM, not a section: no eyebrow, no rule, no plate, and it does
   not take a `.port` — it is the last inch of the hero, printed on the page
   instead of on the picture. Which is the whole point of it being here. Over
   the film this sentence measured 1.94:1 at its worst against the closing
   sky and needed a gradient built underneath it to survive; on --ink it is
   --mist at 8.9:1 and needs nothing at all.

   Centred, because it is not a heading and not a control — nothing follows
   from it, so nothing should be aligned to it. The rule above it is the
   film's own bottom edge, which .hero-art::after has already ramped to solid
   --ink by 100%, so the two grounds are the same colour and the seam does
   not show.

   ★ NO BACKGROUND OF ITS OWN, and that is not an omission. It was written
   with `background: var(--ink)` to guarantee the seam matched, and the ink
   matched so well that nobody would have caught what it actually did: this
   element is opaque and full-bleed, so it painted a dead band across the
   fixed starfield — no stars for its whole height — and on a phone it cut
   the ship in half as she sailed past, because she crosses exactly here.
   The page already has the right ground under it. Anything laid over the
   sky on this site has to be transparent or it leaves a hole in it. */
.hero-sill {
  margin: 0; padding: clamp(26px, 4.2vh, 44px) var(--gutter) clamp(4px, 1vh, 12px);
  text-align: center;
  font-size: clamp(1.02rem, 1.7vw, 1.24rem); font-style: italic;
  color: var(--mist); letter-spacing: .01em;
}
/* The eyebrow was a pill, and a pill is the other shape that says "modern
   website" before it says anything else. Same cut-corner plate as .btn,
   scaled down: it now reads as a stamped label on a chart rather than a tag
   on a blog. Built the same way and for the same reason — clip-path would
   have eaten a real border along the cuts. */
.eyebrow {
  --cut: 6px;
  --plate: polygon(
    var(--cut) 0, calc(100% - var(--cut)) 0,
    100% var(--cut), 100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%, var(--cut) 100%,
    0 calc(100% - var(--cut)), 0 var(--cut));
  position: relative; isolation: isolate;
  display: inline-block; font-family: var(--f-display); font-weight: 600;
  font-size: .72rem; letter-spacing: var(--track-label); text-transform: uppercase;
  color: var(--gold); border: 0; background: none;
  padding: 7px 15px 6px; border-radius: 0; margin-bottom: 22px;
}
.eyebrow::before, .eyebrow::after { content: ""; position: absolute; clip-path: var(--plate); }
.eyebrow::before { inset: 0; z-index: -2; background: color-mix(in srgb, var(--gold) 30%, transparent); }
.eyebrow::after  { inset: 1px; z-index: -1; background: color-mix(in srgb, var(--gold) 9%, var(--ink)); }
/* `.hero h1`, `.hero .tagline` and `.hero .standfirst` are GONE, not moved.
   The h1 is `.vh` and has been for a long time, so its display sizing was
   already decorative-only; the standfirst had no markup left anywhere on the
   site; and the tagline's whole rule existed to win a contrast fight against
   the film (--starlight instead of --mist, measured 2.05:1 at --mist over
   the closing card). It does not have that fight any more — it is on --ink
   in `.hero-sill` above, at --mist, at 8.9:1. The fix for the contrast was
   to stop standing on the picture. */
.cta-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }

/* ============================================================
   THE CHART PLATE — the button shape
   ============================================================
   What was here was a rounded rectangle with a top-lit three-stop gradient
   and a soft coloured glow under it. Those three things together are the
   default "gold CTA" that every generated landing page ships, and no amount
   of correct palette rescues them: the SHAPE was the tell, not the colour.

   ★ THE SHAPE IS NOW A CUT-CORNER PLATE, and the corners are the whole
   idea. A 45° chamfer is a made object — a foil-stamped panel on a case
   binding, a brass plate screwed to a hull, a cartouche inked on a chart.
   It is also, read closely, the site's own port marker: the diamonds
   voyage.js draws along the route are squares turned 45°, and each cut
   corner of this plate is a quarter of one. The button is a diamond
   stretched wide enough to hold a word. Nothing else on the web looks like
   this, because a border-radius cannot make it.

   The gold is unchanged and stays the one large gold element per screen.
   What changed is how it is lit: the glossy gradient is replaced by a long
   flat field with a lift at the top and a fall at the bottom, raked 4° off
   vertical, which is how foil behaves — it does not shine evenly, it catches
   a line of light. And the glow blob is gone. The plate is seated with a
   plain dark shadow like a thing lying on paper.

   HOW IT IS BUILT, and why it has to be this way:
   - `clip-path` is what cuts the corners, and clip-path also clips BORDERS
     and BOX-SHADOWS — a bordered chamfer loses its border along the cuts,
     and a box-shadow disappears entirely. So the rim is not a border: it is
     a whole chamfered layer (`::before`) with the field (`::after`) sitting
     1.5px inside it, and the seating shadow is `filter: drop-shadow()`,
     which follows the clipped silhouette instead of being cut away by it.
   - Both pseudos take negative z-index inside the button's own stacking
     context (`isolation: isolate`), so they paint above the element's
     background and below its text without any wrapper span. The markup did
     not change on a single page.
   - `--plate` is one polygon defined once and inherited. The inner layer is
     inset 1.5px and reuses the same `--cut`: insetting a 45° chamfer by d
     shortens the cut by only 0.41d, which at 1.5px is six tenths of a pixel.
     Not worth a second polygon. */
.btn {
  --cut: 10px;
  --plate: polygon(
    var(--cut) 0, calc(100% - var(--cut)) 0,
    100% var(--cut), 100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%, var(--cut) 100%,
    0 calc(100% - var(--cut)), 0 var(--cut));
  position: relative; isolation: isolate;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--f-display); font-weight: 600;
  font-size: .82rem; letter-spacing: .12em; text-transform: uppercase;
  text-decoration: none; padding: 15px 28px 14px;
  border: 0; border-radius: 0; background: none;
  transition: transform .25s var(--ease-out-expo), filter .3s ease, color .25s ease;
}
.btn::before, .btn::after {
  content: ""; position: absolute; clip-path: var(--plate);
  transition: background .3s ease;
}
.btn::before { inset: 0; z-index: -2; }      /* the rim */
.btn::after  { inset: 1.5px; z-index: -1; }  /* the field */

/* --- primary: a foil-stamped plate --- */
.btn-gold {
  color: #241A0C;
  /* Room on the left for the port diamond, which is painted into the
     field's background rather than added to the markup — this class is used
     on six pages and none of them had to be touched. */
  padding-left: 44px;
  filter: drop-shadow(0 5px 12px rgba(3, 8, 18, .55));
}
.btn-gold::before { background: color-mix(in srgb, var(--gold-deep) 72%, var(--ink)); }
.btn-gold::after {
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 10 10'%3E%3Cpath d='M5 0 10 5 5 10 0 5Z' fill='%23241A0C'/%3E%3C/svg%3E")
      no-repeat 22px 51%,
    linear-gradient(176deg,
      color-mix(in srgb, var(--gold-bright) 74%, var(--gold)) 0%,
      var(--gold) 20%,
      var(--gold) 76%,
      color-mix(in srgb, var(--gold) 80%, var(--gold-deep)) 100%);
}
/* The rim catches the lantern. 1px lift, not 2 — a plate is heavy. */
.btn-gold:hover, .btn-gold:focus-visible {
  transform: translateY(-1px); color: #241A0C;
  filter: drop-shadow(0 7px 15px rgba(3, 8, 18, .6))
          drop-shadow(0 0 11px color-mix(in srgb, var(--gold) 42%, transparent));
}
.btn-gold:hover::before, .btn-gold:focus-visible::before { background: var(--gold-bright); }
.btn-gold:active { transform: translateY(0); }

/* --- secondary: the same plate, empty. An unclaimed berth on the chart. --- */
.btn-ghost { color: var(--mist); }
.btn-ghost::before { background: color-mix(in srgb, var(--mist) 30%, transparent); }
.btn-ghost::after  { background: color-mix(in srgb, var(--field) 52%, transparent); }
.btn-ghost:hover, .btn-ghost:focus-visible { color: var(--starlight); transform: translateY(-1px); }
.btn-ghost:hover::before, .btn-ghost:focus-visible::before { background: color-mix(in srgb, var(--gold) 50%, transparent); }
.btn-ghost:hover::after, .btn-ghost:focus-visible::after { background: color-mix(in srgb, var(--field) 74%, transparent); }
.btn-ghost:active { transform: translateY(0); }

/* Focus: the rim grows into a ring that follows the chamfer, because a
   rectangular outline round a cut-corner plate looks like a mistake. The
   real `outline` is kept and made transparent rather than removed — Windows
   High Contrast repaints a transparent outline and paints nothing at all
   for `outline: none`, so dropping it would lose the focus ring for exactly
   the readers who most need it. */
.btn:focus-visible { outline: 2px solid transparent; outline-offset: 3px; }
.btn:focus-visible::before { inset: -3px; }
.scroll-cue {
  position: absolute; left: 50%; bottom: 26px; z-index: 2; transform: translateX(-50%);
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  font-family: var(--f-display); font-size: .68rem; font-weight: 600;
  letter-spacing: .26em; text-transform: uppercase; color: var(--starlight);
  text-decoration: none;
}
/* --starlight, not --mist: the cue sits at the foot of the hero where the
   film's closing card is bright behind it, and mist measured 4.34:1 there —
   under the 4.5 this 11px label needs.

   ★ THE CUE CANNOT LIVE ON THE FLOOR FADE ANY MORE, so it carries its own
   ground — the same move .site-header makes. The old note here said
   starlight cleared 4.5 "without touching the floor fade, which would have
   dimmed the card's own 'available at amazon' line". True, but the fade was
   ALREADY dimming that line to 1.89:1; softening it (see .hero-art::after)
   was not optional, and it took the cue's ground with it.

   Nothing fixed can be done with colour alone here. Measured at the cue's
   own footprint every half second through the film, the shot swings from a
   warm mid-tone [149,131,116] at t=0, to a dark aurora [44,29,61] at t=7, to
   the pale arrival sky [167,194,209] at rest. Light text scores 1.66:1 on
   the last of those and dark text scores 1.01:1 on the middle one — there is
   no ink that survives the whole film, which is exactly why a ground is the
   answer and not a palette change.

   It is an ellipse sitting ON the hero's bottom edge, widest there and
   fading upward, so it reads as the hem swelling under the cue rather than
   as a disc floating on the sky. Over the film's dark passages it is
   invisible; it only does work when the shot behind it is bright. */
.scroll-cue::before {
  content: ""; position: absolute; z-index: -1; pointer-events: none;
  left: 50%; bottom: -26px; transform: translateX(-50%);
  width: min(560px, 62vw); height: 150px;
  background: radial-gradient(ellipse 50% 100% at 50% 100%,
    color-mix(in srgb, var(--ink) 44%, transparent) 0%,
    color-mix(in srgb, var(--ink) 26%, transparent) 55%,
    transparent 82%);
}
.scroll-cue svg { animation: bob 2.6s ease-in-out infinite; }
@keyframes bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(7px); } }

/* ★ PORTRAIT: THE FILM IS SHOWN WHOLE, NOT CROPPED.
   `cover` at a phone's 0.46 aspect keeps only about the middle quarter of
   the width. The ship survives that — she is composed centre — but the
   CLOSING CARD DOES NOT: its ADALASIA runs nearly the full frame width, and
   cropped it reads "ALAS" over "OOK ONE · THE JA". That is the exact failure
   the hero plan predicted for anything full-width baked into the frames, and
   the card is the payoff, so losing it on a phone loses the point.

   So in portrait the hero stops being a full-bleed backdrop and becomes a
   band: the film at its own 16:9, whole, with the copy underneath it on the
   page's own ground — where it needs no scrim at all and reads at full
   contrast. The hero also stops being a full screen tall, because a 16:9
   band plus three lines of copy is not a screen's worth of anything. */
@media (max-aspect-ratio: 3/4) {
  .hero { min-height: 0; }
  .hero-stage {
    position: relative; min-height: 0; height: auto;
    display: block; padding: 0 0 clamp(28px, 5vh, 48px);
  }
  .hero-art {
    position: relative; inset: auto; z-index: auto;
    height: auto; aspect-ratio: 16 / 9;
    /* ★ WAS A HARDCODED 74px. The header is not 74px tall — it is 58 on a
       phone, 68 on a desktop, and it was 96 on the 900px screens where the
       nav used to wrap, which slid the film up UNDER the bar. js/site.js
       measures the real height into --header-h on every resize; the token's
       own default covers first paint and scripting-off. */
    margin-top: calc(var(--header-h) + 6px);
  }
  .hero-video, .hero-still { object-fit: contain; height: 100%; }
  .hero-art::after { display: none; }   /* nothing sits over the film now */
  /* The hero is a 16:9 band here, not a screen. The mark is measured off the
     BAND's top edge (the header's clearance plus a sixth of the band) rather
     than off the viewport, or on a tall phone it lands below the film
     entirely and sits on the page's own ground. */
  .hero-wordmark { top: calc(var(--header-h) + 6px + 11.25vw); width: 62vw; }
  /* ★ THE CARD NEEDS A HARDER EDGE HERE, and the reason is geometry, not
     colour. Measured in the mark's own band, the gold sits at 1.05:1 against
     the mean sky on a phone — and 1.04:1 on a 1440 desktop, which is the same
     number. It is legible on the desktop anyway because what separates it is
     the SHADOW, not the field: at 640px wide the glyph strokes are thick
     enough that a 9px blur reads as an edge. At 62vw on a 390 phone the mark
     is 233px wide, the strokes are a third as thick, and the same 9px blur is
     wider than the stroke it is supposed to outline — so it stops being an
     edge and becomes a smudge, and ADALASIA dissolves into the cloud.

     The film is `contain` here, which makes it worse: the landscape crop
     keeps the frame's darker top strip behind the mark, while the whole
     16:9 band puts it over the brightest cloud in the shot.

     So: one tight, nearly opaque shadow UNDER the existing stack, sized to
     the stroke rather than the mark. The picture is not dimmed — a scrim
     would have cost the whole opening of the painting to fix 233px of type.
     The list keeps the same function order as the base rule so `transition:
     filter` can still interpolate it. */
  .hero-wordmark img {
    filter:
      brightness(var(--wm-b, .84))
      saturate(var(--wm-s, 1))
      hue-rotate(var(--wm-h, -6deg))
      drop-shadow(0 1px 2px rgba(6, 10, 20, .9))
      drop-shadow(0 2px 9px rgba(6, 10, 20, var(--wm-ink, .6)))
      drop-shadow(0 0 26px rgba(232, 179, 106, var(--wm-glow, .04)));
  }
  /* The sill was already doing this job in portrait — copy under the band,
     on the page's own ground — for the whole hero. Now that is what the
     hero is at every size, so there is nothing left to override; it only
     needs its top padding trimmed, because the film band above it is not a
     full screen and does not want a full screen's worth of air after it. */
  .hero-sill { padding-top: clamp(20px, 3vh, 30px); }
  /* No cue here. The hero is a band now, not a screen, so the section below
     it is already visible — a "begin the voyage" arrow would be pointing at
     something the reader can see, and the ship crosses it on her way past. */
  .scroll-cue { display: none; }
}

/* ---------- galaxy markers (js/galaxy.js) ----------
   Each world is a real <button> that galaxy.js parks over its point by
   projecting the same matrices the shaders use. The button, not a
   distance test, IS the hit target — which is how this works on touch
   and with a keyboard for free. */
.galaxy-points {
  position: absolute; inset: 0; z-index: 1;
  margin: 0; padding: 0; list-style: none; pointer-events: none;
  /* --gp-dive is written by galaxy.js as the hero descends: the worlds
     annotate the establishing shot and then hand off. It is exactly 1
     until the descent starts, and opacity:1 creates no stacking
     context, so the composed frame is untouched. The tab order, the
     focus rescue and pointer-events are NOT done here — those go
     through the same is-occluded path everything else uses. */
  opacity: var(--gp-dive, 1);
}
.galaxy-points > li { display: block; }
.gp {
  position: absolute; top: 0; left: 0;
  /* --gp-size is measured by galaxy.js from how close two markers ever
     get in this viewport: 44 where there is room, never under the WCAG
     2.5.8 minimum of 24, so neighbours cannot overlap. The negative
     margin centres the box on the projected point, so the per-frame
     transform stays a bare translate. */
  width: var(--gp-size, 44px); height: var(--gp-size, 44px);
  margin: calc(var(--gp-size, 44px) / -2) 0 0 calc(var(--gp-size, 44px) / -2);
  display: grid; place-items: center;
  background: none; border: 0; padding: 0; cursor: pointer;
  pointer-events: auto; will-change: transform;
  transition: opacity .35s ease;
}
.gp-dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--gold-bright);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 22%, transparent),
              0 0 12px 2px color-mix(in srgb, var(--gold) 60%, transparent);
  transition: transform .3s var(--ease-out-expo), box-shadow .3s ease;
}
/* Ada-Prime is a hollow ring, not a bright point. A filled white dot
   with a halo sitting at the exact galactic centre is the most
   star-like thing that could possibly be there, and Book One names no
   star. A ring reads as a place marked on a chart. */
.gp-core .gp-dot {
  width: 13px; height: 13px;
  background: none;
  border: 1.5px solid var(--gold-bright);
  box-shadow: 0 0 8px color-mix(in srgb, var(--gold) 45%, transparent);
}
.gp-core:hover .gp-dot, .gp-core.is-active .gp-dot, .gp-core:focus-visible .gp-dot {
  transform: scale(1.35);
  background: color-mix(in srgb, var(--gold-bright) 30%, transparent);
}
.gp-name {
  position: absolute; top: 30px; left: 50%; transform: translateX(-50%);
  white-space: nowrap; font-family: var(--f-display); font-weight: 600;
  font-size: .68rem; letter-spacing: .18em; text-transform: uppercase;
  color: var(--starlight);
  /* two shadows: a tight one to hold the letterforms against the bright
     core, a wide one to sit them off the arms */
  text-shadow: 0 0 4px var(--ink-deep), 0 0 14px var(--ink-deep), 0 2px 10px var(--ink);
  opacity: 0; transition: opacity .3s ease; pointer-events: none;
}
/* near an edge the name grows inward from its dot instead of being
   centred on it, so it never runs off the page */
.gp.lbl-out .gp-name { left: 50%; right: auto; transform: none; text-align: left; }
.gp.lbl-in  .gp-name { right: 50%; left: auto; transform: none; text-align: right; }
.gp-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.gp:hover .gp-dot, .gp.is-active .gp-dot, .gp:focus-visible .gp-dot { transform: scale(1.7); }
.gp:hover .gp-name, .gp.is-active .gp-name, .gp:focus-visible .gp-name { opacity: 1; }
.gp:focus-visible { outline: 2px solid var(--gold); outline-offset: 0; border-radius: 50%; }
/* the far side of the disc dims but stays reachable — hiding it would
   shuffle the tab order under the user's hands */
.gp.is-far { opacity: .45; }
/* ...but never dim the thing the user is pointing at or focused on:
   at .45 the gold ring falls to about 2.6:1 against the sky */
.gp.is-far:hover, .gp.is-far:focus-visible, .gp.is-far.is-active { opacity: 1; }
.gp.is-occluded { opacity: 0; pointer-events: none; }

.galaxy-readout {
  position: absolute; z-index: 2; right: var(--gutter); bottom: clamp(84px, 12vh, 128px);
  width: min(26rem, calc(100% - var(--gutter) * 2));
  margin: 0; padding: 20px 22px;
  background: color-mix(in srgb, var(--field) 82%, transparent);
  -webkit-backdrop-filter: blur(8px);
          backdrop-filter: blur(8px);
  border: 1px solid var(--panel-border); border-radius: var(--radius);
  box-shadow: var(--shadow-ambient);
  opacity: 0; visibility: hidden; transform: translateY(8px);
  transition: opacity .3s ease, transform .3s var(--ease-out-expo), visibility .3s;
  pointer-events: none;
}
.galaxy-readout.is-on { opacity: 1; visibility: visible; transform: none; }
.galaxy-readout .gr-kind {
  display: block; font-family: var(--f-display); font-size: .68rem; font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase; color: var(--gold);
}
.galaxy-readout .gr-name {
  display: block; font-family: var(--f-display); font-size: 1.3rem; font-weight: 600;
  letter-spacing: .08em; color: var(--starlight); margin: 6px 0 8px;
}
.galaxy-readout .gr-line { display: block; font-size: 1.02rem; color: var(--mist); font-style: italic; }

.galaxy-hint {
  font-family: var(--f-display); font-size: .68rem; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--mist-dim);
  margin: 22px 0 0;
}
.galaxy-hint .on-tap { display: none; }
/* coarse pointers have no hover, so they get told to tap instead */
@media (hover: none) {
  .galaxy-hint .on-hover { display: none; }
  .galaxy-hint .on-tap { display: inline; }
}
/* Narrow: no room beside the text, so the galaxy takes the top of the
   hero and the copy is pushed down under it. galaxy.js moves the camera
   to match — the two have to change together or the markers end up
   behind the headline with nowhere to go. */
@media (max-width: 980px) {
  /* This used to be `padding-top: 55svh` + `.scroll-cue { display: none }`,
     both of which existed for ONE reason: on a narrow screen the galaxy was
     stacked above the copy, which made the hero taller than the viewport and
     left the cue below the fold pointing at nothing. The galaxy is retired
     and the film needs no such reservation — it either fills the hero
     (landscape) or sits in a band above the copy (portrait, see the
     max-aspect-ratio block). Both rules are gone; the cue comes back. */
  .hero-stage { align-content: start; padding-bottom: 84px; }
  .gp-name { font-size: .62rem; letter-spacing: .14em; }
  .gp-dot { width: 6px; height: 6px; }
  /* Docked strip: a floating card has nowhere to go on a phone.
     FIXED, not absolute — the hero is taller than the viewport here, so
     anchoring to the hero would park the strip below the fold. */
  .galaxy-readout {
    position: fixed; z-index: 9;
    left: 0; right: 0; bottom: 0; width: auto; border-radius: 0;
    border-left: 0; border-right: 0; border-bottom: 0;
    background: color-mix(in srgb, var(--field) 92%, transparent);
    padding: 16px var(--gutter) 20px;
  }
  .galaxy-readout .gr-name { font-size: 1.15rem; margin: 4px 0 6px; }
  .galaxy-readout .gr-line { font-size: .98rem; }
}

/* ---------- sections / ports ----------

   ★★ THERE ARE TWO HEADER TIERS NOW, AND THAT IS THE WHOLE FIX FOR THE
   "every section is the same" problem.

   Read cold, this page was described as scroll slop, and the diagnosis
   offered was "stop making every section 100vh". Measured, no section was
   ever 100vh — nothing here has a height at all, only padding. But the
   OUTCOME was the same, and the numbers say why: at 1440x900 the blocks
   came out 1.07, 1.12, 0.91, 0.75, 0.79 and 1.13 screens. Six blocks all
   landing within a hair of one screen, without a single height in the
   stylesheet, is not a coincidence — it is what happens when six different
   amounts of content are all given the same 150px of padding and the same
   ceremonial header: rule, gold pill, 2.7rem centred Cinzel, 56px of air.
   The heading treatment was doing the sizing.

   So height is not the control. Ceremony is.

   TIER 1, `.rule-head` + centred `.port h2`, is reserved for the three
   moments that can carry it: NOW BOARDING, THIRTEEN SECONDS ABOVE TRENCE,
   ADALASIA. Rare is the point — a big centred title means something only
   while most titles are not.

   TIER 2, `.port--compact` + `.head-compact`, is an editorial masthead:
   eyebrow and title on ONE line, left, over a hairline, at less than half
   the type size and a third of the vertical spend. The Author, The Imprint
   and Ship's Log take it.

   Padding drops with it (150 -> 80 max), which is worth about a screen and a
   half over the page. */
main { position: relative; z-index: 2; }
/* The gutter carries the notch inset with it, so a landscape iPhone does not
   run the first character of a line under the rounded corner. `env()`
   resolves to 0 on every device that has no notch, so this costs nothing
   and needs no query. */
.port {
  position: relative;
  padding: clamp(90px, 13vh, 150px) calc(var(--gutter) + var(--safe-r)) clamp(90px, 13vh, 150px) calc(var(--gutter) + var(--safe-l));
}
.port-inner { max-width: var(--container); margin: 0 auto; }
/* A shared container. read.html's markup already asked for `.wrap` in two
   places and nothing had ever defined it, so that page's footer sat flush
   against the left edge of the window at every width. */
.wrap { max-width: var(--container); margin-inline: auto; padding-inline: calc(var(--gutter) + var(--safe-l)) calc(var(--gutter) + var(--safe-r)); }

/* --- tier 1: the ceremonial header --- */
.rule-head { display: flex; align-items: center; gap: 20px; margin-bottom: 14px; }
.rule-head::before, .rule-head::after {
  content: ""; height: 1px; flex: 1;
  background: linear-gradient(90deg, transparent, var(--hairline));
}
.rule-head::after { background: linear-gradient(90deg, var(--hairline), transparent); }
.rule-head .eyebrow { margin: 0; }
.port h2 {
  font-size: clamp(1.9rem, 4.2vw, 2.7rem); letter-spacing: .06em; text-align: center;
  margin-bottom: clamp(34px, 5vh, 56px);
}

/* --- tier 2: the editorial masthead --- */
.port--compact { padding-block: clamp(52px, 7vh, 80px); }
.head-compact {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 18px;
  padding-bottom: 14px; margin-bottom: clamp(26px, 3.4vh, 38px);
  border-bottom: 1px solid var(--hairline);
}
.head-compact .eyebrow { margin: 0; flex: none; }
/* Out-specifies `.port h2` above (two classes to one), which is what stops
   the compact title inheriting the centred 2.7rem treatment. */
.port--compact .head-compact h2 {
  font-size: clamp(1.35rem, 2.2vw, 1.6rem); letter-spacing: .07em;
  text-align: left; margin: 0; flex: 1 1 auto;
}
/* The entry count sits at the far end of the rule. Right-aligned by
   margin-left:auto rather than justify-content, so it stays put when the
   header wraps on a narrow screen instead of jumping under the title. */
.head-count {
  margin-left: auto; font-family: var(--f-display); font-size: .68rem; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--mist-dim);
}

/* featured book */
/* ★ `min-width: 0` ON THE TRACKS, and it is the fix for a real overflow.
   A grid item's automatic minimum size is its MIN-CONTENT width, not zero,
   so a single unbreakable child can push a track wider than the column it
   was given and take the whole page with it. Measured at 320px: the three
   edition tabs need 319px on one row, `.book-meta` refused to go below that,
   and the document ended up 365px wide inside a 320px window — a horizontal
   scrollbar on the site's smallest supported phone. The tabs learn to wrap
   below (see `.edition-tabs`); this is the half that stops any FUTURE wide
   child doing the same thing. */
.book-feature {
  display: grid; grid-template-columns: minmax(260px, 380px) minmax(0, 1fr);
  gap: clamp(36px, 6vw, 84px); align-items: center;
}
.book-feature > * { min-width: 0; }
.book-cover-card { position: relative; justify-self: center; max-width: min(380px, 100%); }
.book-cover-card img {
  border-radius: 4px;
  box-shadow: var(--shadow-lift), 0 0 0 1px color-mix(in srgb, var(--gold) 22%, transparent);
  transition: transform .5s var(--ease-out-expo), box-shadow .5s ease;
}
.book-cover-card::before {
  content: ""; position: absolute; inset: -12%;
  background: radial-gradient(ellipse 60% 55% at 50% 46%, color-mix(in srgb, var(--mist) 13%, transparent), transparent 70%);
  z-index: -1;
}
a.book-cover-link:hover img, a.book-cover-link:focus-visible img { transform: translateY(-8px) scale(1.012); box-shadow: 0 2.4rem 4rem -1.4rem rgba(3, 8, 18, 1), 0 0 0 1px color-mix(in srgb, var(--gold) 40%, transparent); }
.book-meta .series {
  font-family: var(--f-display); font-size: .8rem; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--mist); margin-bottom: 10px;
}
.book-meta h3 { font-size: clamp(2.1rem, 4.6vw, 3rem); letter-spacing: .1em; margin-bottom: 4px; }
.book-meta .byline { font-style: italic; color: var(--mist); margin-bottom: 20px; }
.book-meta .blurb { max-width: 33rem; }
.book-meta .status {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: var(--f-display); font-size: .74rem; font-weight: 600;
  letter-spacing: .16em; text-transform: uppercase; color: var(--gold);
  margin: 6px 0 26px;
}
.book-meta .status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--gold); box-shadow: 0 0 8px var(--gold); }

/* --- the book as an object -------------------------------------------
   A pointer-driven parallax of at most 4 degrees plus a specular sweep
   that tracks the same pointer. js/site.js writes --tx/--ty (rotation) and
   --mx/--my (the highlight's centre); everything else is here, so with
   scripting off the card is exactly what it was.

   The transition is short rather than absent: at 0.28s it smooths the
   jitter of a hand-held mouse without lagging behind it, and it is also
   what eases the card back to flat on pointerleave — no second code path
   for the return.

   `perspective` on the CARD and the transform on the LINK inside it, not
   both on one element: a perspective and a transform on the same box are
   resolved with the vanishing point at that box's own centre, which makes
   the rotation read as a skew instead of a turn. */
.book-cover-card { perspective: 1200px; }
.book-cover-link {
  display: block; position: relative; border-radius: 4px;
  transform: rotateX(var(--ty, 0deg)) rotateY(var(--tx, 0deg));
  transform-style: preserve-3d;
  transition: transform .28s var(--ease-out-expo);
}
.cover-sheen {
  position: absolute; inset: 0; border-radius: 4px; pointer-events: none;
  opacity: 0; transition: opacity .35s ease;
  background: radial-gradient(38% 30% at var(--mx, 50%) var(--my, 50%),
              color-mix(in srgb, var(--gold-bright) 26%, transparent),
              transparent 72%);
  mix-blend-mode: screen;
}
.book-cover-card[data-tilting] .cover-sheen { opacity: 1; }

/* --- the editions strip ----------------------------------------------
   A real tablist: one panel visible, the other two `hidden`. The tabs are
   segmented rather than pill-shaped so they read as one control — three
   states of a single object, which is what they are — and the selected one
   is marked by a gold rule under it, not by a filled background. There is
   already one large gold element on this screen (the Order button) and the
   colour brief in tokens.css allows exactly one.

   auto-fit + minmax lets the five spec cells reflow 5 / 3+2 / 2+2+1 by
   themselves, so this never needs a breakpoint of its own. */
.editions { margin: 4px 0 26px; max-width: 34rem; }
/* The same cut-corner plate, at strip scale, so the control and the action
   below it read as one family. clip-path on the container is what keeps a
   hovered end-tab's background inside the cut — but it also clips the tabs'
   focus rings, which is why they take `outline-offset: -3px` below and draw
   theirs on the inside. */
.edition-tabs {
  --cut: 8px;
  --plate: polygon(
    var(--cut) 0, calc(100% - var(--cut)) 0,
    100% var(--cut), 100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%, var(--cut) 100%,
    0 calc(100% - var(--cut)), 0 var(--cut));
  position: relative; isolation: isolate;
  display: flex; gap: 0; border: 0; border-radius: 0;
  clip-path: var(--plate); width: fit-content; max-width: 100%;
  /* ★ IT WRAPS BEFORE IT BREAKS THE PAGE (2026-08-14). Three tabs on one
     row need 319px; a 320px phone has 280 between its gutters. The strip
     was the widest unbreakable thing on the homepage and it was pushing the
     document 45px wider than the window. Wrapping is the right answer
     rather than shrinking the type: the tabs stay one control (the plate,
     the shared rim and the dividing hairlines all still read across the
     break) and the labels stay at a size a thumb can aim at. */
  flex-wrap: wrap;
}
.edition-tabs::before, .edition-tabs::after { content: ""; position: absolute; clip-path: var(--plate); }
.edition-tabs::before { inset: 0; z-index: -2; background: var(--hairline); }
.edition-tabs::after  { inset: 1px; z-index: -1; background: var(--ink); }
.edition-tabs button {
  appearance: none; background: none; border: 0; cursor: pointer;
  font-family: var(--f-display); font-size: .7rem; font-weight: 600;
  letter-spacing: .14em; text-transform: uppercase; color: var(--mist-dim);
  padding: 10px 16px 8px; border-bottom: 2px solid transparent;
  transition: color .25s ease, background .25s ease, border-color .25s ease;
}
.edition-tabs button + button { border-left: 1px solid var(--hairline); }
.edition-tabs button:focus-visible { outline-offset: -3px; }
.edition-tabs button:hover { color: var(--starlight); background: color-mix(in srgb, var(--field) 45%, transparent); }
.edition-tabs button[aria-selected="true"] {
  color: var(--gold-bright); border-bottom-color: var(--gold);
  background: color-mix(in srgb, var(--gold) 8%, transparent);
}
.edition-panel {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
  gap: 16px 22px; margin: 20px 0 0;
}
.edition-panel[hidden] { display: none; }
.edition-panel dt {
  font-family: var(--f-display); font-size: .64rem; font-weight: 600;
  letter-spacing: .18em; text-transform: uppercase; color: var(--mist-dim);
  margin-bottom: 3px;
}
.edition-panel dd { margin: 0; color: var(--starlight); font-size: 1.02rem; line-height: 1.35; }

/* A tab is a control, and on a touch screen a control wants a thumb's worth
   of height. 30px was fine for a mouse and mean on a phone. Scoped to coarse
   pointers so the desktop strip keeps its compact proportions. */
@media (pointer: coarse) {
  .edition-tabs button { min-height: 44px; padding-block: 12px 10px; }
}
@media (max-width: 900px) {
  .book-feature { grid-template-columns: minmax(0, 1fr); }
  .book-cover-card { max-width: min(300px, 100%); }
  .book-meta { text-align: center; }
  .book-meta .blurb { margin-inline: auto; }
  .book-meta .cta-row { justify-content: center; }
  .editions { margin-inline: auto; text-align: left; }
  .edition-tabs { margin-inline: auto; }
}
/* The smallest phone the site supports. Padding, not type size, is what
   gives: at 8px a side the three tabs need 271px against the 280 a 320px
   screen has between its gutters, and the labels are still 11px. */
@media (max-width: 400px) {
  .edition-tabs button { padding-inline: 8px; letter-spacing: .1em; }
}

/* ---------- the world ----------
   THE CARDS CARRY NO PICTURE. Each one used to be a <button> wrapping a
   292px still from the author's trailers, opening a dialog with the same
   picture at 760 and a paragraph of lore under it. The stills are gone
   (author: they "look super ai"), and with them the reason the dialog
   existed — its payload was the bigger JPEG, and a modal that opens to show
   one paragraph is ceremony. The lore moved onto the card. See the note in
   index.html.

   What is behind them is now the section's picture: `warplate_loop`, cut
   from the author's own four ship-fight masters. See "the world's plate"
   near the foot of this file for why its opacity is what it is — the
   binding constraint moved when the cards did, and the number moved with
   it. */
.world-notes {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(24px, 4vw, 52px);
  max-width: 980px; margin: 0 auto;
}
/* THE CHART PLATE AGAIN, at card scale. Same 45° chamfer as .btn and
   .eyebrow, built the same way and for the same reason: clip-path cuts
   borders and box-shadows away along the cuts, so the rim is a whole
   chamfered layer (::before) with the field (::after) sitting 1px inside
   it. Both take negative z-index inside the card's own stacking context, so
   they paint above the section's plate and below the card's text with no
   extra markup.

   The field is 68% ink, and that number is measured, not chosen. It shipped
   at 34% on the theory that a card sitting IN the picture should barely be
   there — which looked right and failed: sampled at the loop's brightest
   frame (the explosion at 5.19s, peak luma 251) the worst pixel inside a
   card came out (89,85,75), putting the 13px gold title at 3.44:1 and the
   body at 3.93:1, both under the 4.5 small text needs. The field is what
   holds the type, so the field is what had to move:

     plate 0.56 / field 34%  ->  gold 3.28:1, body 3.74:1   FAIL
     plate 0.56 / field 55%  ->  gold 4.65:1, body 5.31:1   pass, no margin
     plate 0.56 / field 68%  ->  gold 5.79:1, body 6.61:1   PASS
     plate 0.56 / field 80%  ->  gold 6.97:1, body 7.96:1   pass, opaque

   68% keeps about a third of the war visible through the card and still
   clears 4.5 with room for a recut. Note how little the PLATE matters here
   (0.34 -> 0.68 moves the gold by 0.23 of a ratio point): inside a card the
   field dominates, which is exactly why the plate was free to go up.

   No hover state, and that is deliberate: these are not controls any more.
   Nothing here opens, so nothing should light up as if it does. */
.world-card {
  --cut: 12px;
  --plate: polygon(
    var(--cut) 0, calc(100% - var(--cut)) 0,
    100% var(--cut), 100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%, var(--cut) 100%,
    0 calc(100% - var(--cut)), 0 var(--cut));
  position: relative; isolation: isolate;
  padding: clamp(22px, 2.4vw, 30px) clamp(18px, 2vw, 26px) clamp(24px, 2.6vw, 32px);
}
.world-card::before, .world-card::after { content: ""; position: absolute; clip-path: var(--plate); }
.world-card::before { inset: 0; z-index: -2; background: color-mix(in srgb, var(--gold) 20%, transparent); }
.world-card::after  { inset: 1px; z-index: -1; background: color-mix(in srgb, var(--ink) 68%, transparent); }
/* The chart rail's own diamond, at 7px — a square turned 45°, which is the
   same mark the route draws at every port and the same quarter-shape the
   plate's cut corners are. It is the site's one piece of drawn vocabulary,
   so it is the right thing to reach for when a card needs a mark and the
   answer must not be a new picture. */
.world-card .wc-mark {
  display: block; width: 7px; height: 7px; margin-bottom: 16px;
  background: var(--gold); transform: rotate(45deg);
}
.world-card .wc-title {
  font-family: var(--f-display); font-weight: 600; font-size: .82rem;
  letter-spacing: .16em; text-transform: uppercase; color: var(--gold);
  margin: 0 0 12px;
}
.world-card .wc-lede {
  font-size: 1.05rem; color: var(--starlight); font-style: italic;
  margin: 0 0 .8em;
}
.world-card .wc-body {
  font-size: .96rem; color: var(--mist); margin: 0;
}

/* --starlight, not --mist, and for the same reason the hero's tagline once
   needed it: this line sits on moving footage with nothing between. At
   --mist it measured 3.46:1 against the loop's brightest frame even at the
   old plate opacity — a failure that predates this pass. See .world-plate
   for the table. */
.world-lede { text-align: center; max-width: 40rem; margin: -18px auto clamp(34px, 5vh, 52px); color: var(--starlight); font-style: italic; font-size: 1.15rem; }
@media (max-width: 820px) { .world-notes { grid-template-columns: minmax(0, 1fr); max-width: 460px; } }


/* ---------- the author ----------
   The portrait is UNRETOUCHED (see assets/prep_author.py for why). Its
   background measures (88,88,95) — dark and slightly cool already, so the
   job here is seating, not correction: a frame, a hairline, and a vignette
   that fades the photograph's OWN corners into the page. That is what lets
   a grey studio square sit on a navy sky without anyone editing a face. */
/* Copy first, portrait second, and the portrait is smaller. #first-voyage
   directly above already runs picture-left / text-right; running it again
   here is the repetition that makes a varied page feel like one template.
   260px rather than 300 because the bio is two sentences — a portrait
   should not outweigh what it illustrates. */
.author-feature {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, 260px);
  gap: clamp(32px, 5vw, 64px); align-items: center;
}
.author-feature > * { min-width: 0; }
.author-portrait { position: relative; margin: 0; isolation: isolate; }
.author-portrait img {
  width: 100%; border-radius: var(--radius);
  box-shadow: var(--shadow-lift);
}
/* the glow the book cover gets, so the two portraits of the press — the book
   and the man — are lit the same way */
.author-portrait::before {
  content: ""; position: absolute; inset: -14%; z-index: -1;
  background: radial-gradient(ellipse 60% 58% at 50% 46%,
              color-mix(in srgb, var(--mist) 14%, transparent), transparent 70%);
}
/* the vignette: an inset ring of the page colour over the photo's outer edge.
   Kept to the corners and kept weak — pulled in far enough to touch a face it
   would be retouching by the back door. */
.author-portrait::after {
  content: ""; position: absolute; inset: 0; border-radius: var(--radius);
  pointer-events: none;
  background: radial-gradient(ellipse 78% 78% at 50% 45%,
              transparent 55%, color-mix(in srgb, var(--ink) 55%, transparent) 100%);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 26%, transparent);
}
.author-bio .lede { font-size: 1.32rem; color: var(--starlight); margin-bottom: .7em; }
.author-bio p { max-width: 34rem; }
.author-bio .cta-row { margin-top: 26px; }
@media (max-width: 820px) {
  /* The portrait goes back on top when the two stack: a face under two
     sentences reads as a footnote to them. `order` and not a DOM change,
     so the reading order stays copy-then-picture for a screen reader,
     which is the order that makes sense read aloud. */
  .author-feature { grid-template-columns: minmax(0, 1fr); justify-items: center; text-align: center; }
  .author-portrait { max-width: 200px; order: -1; }
  .author-bio p { margin-inline: auto; }
  .author-bio .cta-row { justify-content: center; }
}

/* --- the community -------------------------------------------------
   Almost nothing, on purpose. The section is built from primitives the
   page already owns, so all it needs is the same measure the author bio
   uses and air above the button. Matching .author-bio's 34rem rather
   than the imprint's 46rem is deliberate: this block is one sentence and
   a call to action, and a single line running the full width of the
   imprint statement reads as a stray caption rather than an invitation. */
.community { max-width: 34rem; }
.community .lede { font-size: 1.32rem; color: var(--starlight); }
.community .cta-row { margin-top: 26px; }
@media (max-width: 820px) {
  .community { margin-inline: auto; text-align: center; }
  .community .cta-row { justify-content: center; }
}

/* --- the imprint: the page's editorial beat --------------------------
   No card. Every other block on the homepage is a picture with words
   beside it, so the one block that is purely a statement is set purely as
   type: an opening line at display size, running text under it, and the
   mark dropped behind the whole thing as a watermark rather than parked in
   a column of its own.

   The watermark is at 0.055 and clipped to the right third. It is a
   texture, not a logo placement — the mark is already in the header and in
   the footer, and a third full-strength copy in between would be branding
   the reader rather than talking to them. */
.imprint-statement { position: relative; max-width: 46rem; }
/* Pushed out past the text column and enlarged rather than darkened: a
   watermark reads as texture when it is bigger and fainter than the type
   and as a misplaced logo when it is smaller and stronger. At .042 over
   --ink it is about four code values — visible as a presence, never as a
   picture — and at 300px it is large enough that the eye files it as paper
   rather than as an object sitting behind the words. */
.imprint-statement .mark {
  position: absolute; right: -132px; top: -58px; width: 300px; height: auto;
  opacity: .042; pointer-events: none; z-index: -1;
}
.imprint-statement .lede {
  font-family: var(--f-display); font-weight: 600; line-height: 1.28;
  font-size: clamp(1.5rem, 3.1vw, 2.05rem); letter-spacing: .02em;
  color: var(--starlight); margin-bottom: .6em;
}
.imprint-statement p { max-width: 40rem; }
.imprint-statement .sign {
  font-family: var(--f-display); font-size: .74rem; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--gold);
  margin: 24px 0 0; padding-top: 18px;
  border-top: 1px solid var(--hairline); display: inline-block;
}
/* ★★ THE WATERMARK WAS PUSHING THE WHOLE DOCUMENT SIDEWAYS (2026-08-14).
   It hangs 132px out past a 46rem statement, and that only has somewhere to
   go while the statement is narrower than its container. Below about 1004px
   it is not: `.port-inner` is `min(100vw, 1100px) - 2 gutters`, so the
   statement fills the column, the mark hangs into the gutter and then
   straight off the page. Measured across all four engines: **+101px of
   horizontal scroll at 768, +81 at 820, +4 at 900** — i.e. a real sideways
   scrollbar on every tablet, from an element nobody can see (opacity .042).
   `body { overflow-x: hidden }` was masking the scrollbar on some engines
   and not on others, which is why it survived this long.

   The rule the note below already makes for phones is simply true up to the
   width where the margin actually exists, so the number moves from 720 to
   1024 and the mark now appears exactly where there is room for it — from
   1025 up it clears the viewport by 116px at the tightest.

   ★ AND IT CANNOT ESCAPE AGAIN. `overflow: clip` with a generous
   `overflow-clip-margin` lets the mark keep bleeding out of the statement
   box (which is the design) while making the overflow non-scrollable, so a
   future change to the type measure can never reintroduce the scrollbar.
   `clip` and not `hidden`: `hidden` would make this element a scroll
   container and swallow `position: sticky` and anchor scrolling inside it. */
.imprint-statement {
  overflow: clip;
  overflow-clip-margin: 160px;
}
@media (max-width: 1024px) {
  /* No margin to put it in. On a phone or a tablet the statement is the
     full column width, so a watermark has nowhere to sit except directly
     behind the type, where at this size it stops reading as paper and
     starts reading as a smudge. The mark is in the header and the footer of
     the same screen; it loses nothing by standing down here. */
  .imprint-statement .mark { display: none; }
}

/* --- ship's log: records, not cards ----------------------------------
   A log is a list with dates down its left edge. Two panels side by side
   was the same shape as the world's three panels two sections earlier, and
   a row can do the one thing a card in that grid could not: go somewhere,
   and say so.

   subgrid is deliberately NOT used — the three columns are declared on the
   row itself so a row that wraps on a phone collapses to its own flow
   instead of inheriting a track sizing solved for a desktop. */
.log-list { list-style: none; margin: 0 0 clamp(38px, 5.5vh, 58px); padding: 0; }
.log-row {
  display: grid; grid-template-columns: 74px 104px minmax(0, 1fr);
  gap: 4px 22px; align-items: baseline;
  padding: 22px 0; border-top: 1px solid var(--hairline);
}
.log-row:last-child { border-bottom: 1px solid var(--hairline); }
.log-row time {
  font-family: var(--f-display); font-size: .78rem; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase; color: var(--gold);
}
.log-kind {
  font-family: var(--f-display); font-size: .64rem; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--mist-dim);
}
.log-body h3 { font-size: 1.16rem; letter-spacing: .05em; margin: 0 0 6px; }
.log-body h3 a { text-decoration: none; color: var(--starlight); }
.log-body h3 a:hover { color: var(--gold-bright); }
.log-body p { color: var(--mist); font-size: 1.02rem; margin: 0; max-width: 42rem; }
.log-arrow { display: inline-block; margin-left: .5em; color: var(--gold); transition: transform .3s var(--ease-out-expo); }
.log-row--link:hover .log-arrow { transform: translateX(5px); }
@media (max-width: 720px) {
  .log-row { grid-template-columns: auto minmax(0, 1fr); }
  .log-body { grid-column: 1 / -1; margin-top: 8px; }
}

.signup {
  max-width: 620px; margin: 0 auto; text-align: center;
  background: linear-gradient(145deg, color-mix(in srgb, var(--field) 90%, var(--gold-deep)), var(--panel));
  border: 1px solid color-mix(in srgb, var(--gold) 24%, transparent);
  border-radius: var(--radius); padding: clamp(30px, 5vw, 48px);
  box-shadow: var(--shadow-ambient);
}
.signup h3 { font-size: 1.5rem; letter-spacing: .08em; }
.signup p { color: var(--mist); }
.signup form { display: flex; gap: 12px; margin-top: 22px; }
.signup input[type="email"] {
  flex: 1; min-width: 0; background: color-mix(in srgb, var(--ink) 70%, transparent);
  border: 1px solid var(--hairline); border-radius: var(--radius-sm);
  color: var(--starlight); font-family: var(--f-body); font-size: 1.05rem;
  padding: 13px 16px;
}
.signup input[type="email"]::placeholder { color: var(--mist-dim); }
.signup input[type="email"]:focus { outline: 2px solid var(--gold); outline-offset: 1px; }
.signup .form-note { font-size: .95rem; color: var(--mist); margin: 14px 0 0; }
.signup .form-msg { font-size: 1.02rem; color: var(--gold-bright); margin: 14px 0 0; min-height: 1.4em; }
@media (max-width: 560px) { .signup form { flex-direction: column; } }

/* ---------- footer ---------- */
.site-footer {
  position: relative; z-index: 2; margin-top: clamp(60px, 9vh, 110px);
  border-top: 1px solid var(--hairline);
  background: linear-gradient(180deg, color-mix(in srgb, var(--field) 55%, var(--ink)), var(--ink-deep));
}
.footer-inner {
  max-width: var(--container); margin: 0 auto;
  padding: 56px calc(var(--gutter) + var(--safe-r)) 30px calc(var(--gutter) + var(--safe-l));
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: clamp(30px, 5vw, 70px);
  align-items: start;
}
.footer-brand img { width: 120px; opacity: .92; }
.footer-links { display: flex; gap: clamp(30px, 5vw, 70px); flex-wrap: wrap; }
.footer-links h3 {
  font-family: var(--f-display); font-size: .72rem; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--mist-dim); margin: 0 0 12px;
}
.footer-links ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.footer-links a { text-decoration: none; font-size: 1.02rem; }
.footer-farewell { font-style: italic; color: var(--mist); text-align: right; max-width: 200px; }
.footer-legal {
  max-width: var(--container); margin: 0 auto;
  padding: 22px calc(var(--gutter) + var(--safe-r)) calc(30px + var(--safe-b)) calc(var(--gutter) + var(--safe-l));
  border-top: 1px solid color-mix(in srgb, var(--mist) 10%, transparent);
  display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between;
  font-size: .95rem; color: var(--mist-dim);
}
@media (max-width: 820px) {
  .footer-inner { grid-template-columns: minmax(0, 1fr); text-align: center; }
  .footer-brand img { margin: 0 auto; }
  .footer-links { justify-content: center; }
  .footer-farewell { text-align: center; max-width: none; }
  .footer-legal { justify-content: center; text-align: center; }
}

/* ---------- 404 (off the chart) ----------
   Was six inline `style` attributes on the page itself. Moved here so the
   404 obeys the same tokens, the same gutter and the same safe-area insets
   as everything else, and so it could take the shared header — which is the
   page that most needs one. */
.lost {
  position: relative; z-index: 2;
  min-height: 100vh; min-height: 100svh;
  display: grid; place-content: center; text-align: center;
  padding: calc(var(--header-h) + 40px) calc(var(--gutter) + var(--safe-r))
           calc(48px + var(--safe-b)) calc(var(--gutter) + var(--safe-l));
}
.lost-jay { width: min(300px, 60vw); margin: 0 auto 8px; transform: rotate(-4deg); }
.lost .eyebrow { margin-inline: auto; }
.lost h1 { letter-spacing: .1em; font-size: clamp(1.8rem, 5vw, 2.6rem); }
.lost-line { color: var(--mist); max-width: 26rem; margin-inline: auto; }
.lost-cta { margin-top: 18px; }

/* ---------- reveal on scroll ---------- */
.reveal { opacity: 0; transform: translateY(26px); transition: opacity var(--reveal-duration) ease, transform var(--reveal-duration) var(--ease-out-expo); }
.reveal.in-view { opacity: 1; transform: none; }
.reveal-slow { transition-delay: .12s; }

/* ---------- book page (the-jay.html) ---------- */
.book-hero { position: relative; z-index: 2; padding: clamp(130px, 18vh, 190px) var(--gutter) clamp(60px, 8vh, 90px); isolation: isolate; }
.book-hero-art { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
.book-hero-art img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 30%; opacity: .5; }
.book-hero-art::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg, color-mix(in srgb, var(--ink) 62%, transparent), color-mix(in srgb, var(--ink) 30%, transparent) 45%, var(--ink) 96%);
}
@media (max-width: 860px) {
  .book-hero-art img { opacity: .38; }
  .book-hero-art::after {
    background: linear-gradient(180deg, color-mix(in srgb, var(--ink) 74%, transparent), color-mix(in srgb, var(--ink) 52%, transparent) 45%, var(--ink) 96%);
  }
}
.book-hero .port-inner { display: grid; grid-template-columns: minmax(240px, 330px) minmax(0, 1fr); gap: clamp(36px, 6vw, 80px); align-items: center; }
.book-hero .port-inner > * { min-width: 0; }
@media (max-width: 860px) {
  .book-hero .port-inner { grid-template-columns: minmax(0, 1fr); }
  .book-hero .book-cover-card { max-width: 270px; }
  .book-hero .book-meta { text-align: center; }
  .book-hero .cta-row { justify-content: center; }
}
.prose { max-width: 660px; margin: 0 auto; }
.prose p { margin-bottom: 1.2em; }
.prose .lede::first-letter {
  font-family: var(--f-display); font-size: 3.1em; float: left;
  line-height: .78; padding: .08em .12em 0 0; color: var(--gold);
}
.specs { max-width: 660px; margin: 0 auto; width: 100%; border-collapse: collapse; }
.specs th, .specs td { text-align: left; padding: 13px 6px; border-bottom: 1px solid color-mix(in srgb, var(--mist) 12%, transparent); }
.specs th {
  font-family: var(--f-display); font-size: .74rem; font-weight: 600;
  letter-spacing: .16em; text-transform: uppercase; color: var(--mist-dim);
  width: 38%; font-style: normal;
}
.specs td { color: var(--text); }
.excerpt-panel {
  position: relative; max-width: 720px; margin: 0 auto; text-align: center;
  background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius);
  padding: clamp(36px, 6vw, 60px); box-shadow: var(--shadow-ambient); overflow: hidden;
}
.excerpt-panel .jay { position: absolute; right: -40px; bottom: -46px; width: 220px; opacity: .55; transform: rotate(-6deg); }
.excerpt-panel p { position: relative; }
.back-link {
  display: inline-flex; align-items: center; gap: 8px; text-decoration: none;
  font-family: var(--f-display); font-size: .74rem; font-weight: 600;
  letter-spacing: .16em; text-transform: uppercase;
}

/* ---------- the film (index.html #film) ----------
   A click-to-play film, not a background. The gold play disc is this
   screen's ONE large gold element, which is why the section carries no
   gold button — see the colour-jobs note in tokens.css.

   A <video> inherits none of the `img {}` reset at the top of this file,
   so width/height/display are set here by hand; aspect-ratio is repeated
   as a belt-and-braces against the moment before metadata arrives, when
   the intrinsic box would otherwise be 300x150 and #voyage would bake
   that wrong page height into its ratchet. */
.film-lede {
  text-align: center; max-width: 40rem; margin: -18px auto clamp(30px, 4.5vh, 46px);
  color: var(--mist); font-style: italic; font-size: 1.15rem;
}
.trailer-frame {
  position: relative; max-width: 900px; margin: 0 auto;
  border-radius: var(--radius); overflow: hidden; background: var(--ink-deep);
  border: 1px solid var(--panel-border);
  box-shadow: var(--shadow-lift), 0 0 0 1px color-mix(in srgb, var(--gold) 12%, transparent);
}
.trailer-video { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; background: var(--ink-deep); }
.trailer-fallback { padding: 2rem; color: var(--mist); }
.trailer-play {
  position: absolute; inset: 0; width: 100%;
  display: grid; place-items: center; align-content: center; gap: 16px;
  border: 0; cursor: pointer; color: var(--starlight);
  font-family: var(--f-display); font-size: .78rem; font-weight: 600;
  letter-spacing: var(--track-label); text-transform: uppercase;
  background: linear-gradient(180deg,
              color-mix(in srgb, var(--ink) 12%, transparent),
              color-mix(in srgb, var(--ink) 52%, transparent));
  transition: background .4s ease;
}
/* Ships `hidden` and is unhidden by site.js. The overlay is only ever
   useful when there is script to drive it: without one it would be a
   large, inviting, completely inert button sitting on top of the film.
   The class rule sets display, so [hidden] has to be restated or it
   would out-specify the UA sheet's display:none. */
.trailer-play[hidden] { display: none; }
.trailer-play:hover, .trailer-play:focus-visible { background: color-mix(in srgb, var(--ink) 18%, transparent); }
.trailer-play .disc {
  width: clamp(66px, 9vw, 92px); aspect-ratio: 1; border-radius: 50%;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--field) 74%, transparent);
  border: 1px solid color-mix(in srgb, var(--gold) 55%, transparent);
  box-shadow: 0 0 30px -6px color-mix(in srgb, var(--gold) 50%, transparent);
  transition: transform .5s var(--ease-out-expo), box-shadow .5s ease;
}
.trailer-play:hover .disc, .trailer-play:focus-visible .disc {
  transform: scale(1.06);
  box-shadow: 0 0 46px -6px color-mix(in srgb, var(--gold) 72%, transparent);
}
.trailer-play .disc svg { fill: var(--gold-bright); margin-left: 5px; }
.trailer-meta {
  max-width: 900px; margin: 18px auto 0;
  display: flex; flex-wrap: wrap; gap: 8px 20px; justify-content: center;
  align-items: baseline; color: var(--mist-dim); font-size: 1rem;
}
.trailer-meta .runtime {
  font-family: var(--f-display); font-size: .72rem; font-weight: 600;
  letter-spacing: .16em; text-transform: uppercase; color: var(--mist);
}

/* ---------- the world's plate ----------
   TRANSLUCENT on purpose. `main { z-index: 2 }` is a stacking context, so
   nothing inside a .port can ever paint below #ship — an opaque backdrop
   would make the ship disappear for this section's whole height and pop
   back out below it. Compositing is by paint order, so at .34 the ship
   really does sail through, dimmed. Keep it low. */
#world { isolation: isolate; }
.world-art { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
/* ★ 0.34 against a LIGHTER scrim (::after below, middle band 55% -> 46%),
   and both numbers are measured rather than chosen.

   First pass shipped 0.22 on the old 55% scrim, on the assumption that
   footage running 94-169 mean luma with an explosion peaking at 253 needed
   holding back where the near-black Trence plate had not. A/B against the
   same frame with the plate switched off measured that at a mean lift of
   **7 code values** — i.e. it had inherited the exact fault the 08-12 audit
   called the worst number on the site (the Trence plate reaching the eye at
   std 3.04). Paying 680 KiB for something nobody can see is worse than not
   shipping it.

   ★★ 0.34 -> 0.56 (2026-08-13). The three stills that used to sit on this
   footage are gone — author: they "look super ai" — which was the whole
   reason it was held down: it was background BEHIND pictures, and a
   background that competes with the pictures on it is just noise. It is the
   section's picture now, so it gets to be seen. The cards that replaced the
   stills carry their own 68% field, so the type they hold no longer depends
   on this number at all (see .world-card).

   THE BINDING CONSTRAINT MOVED to `.world-lede`, the italic line above the
   cards, which sits on the bare plate with nothing between. Sampled at the
   loop's brightest frame (the explosion at 5.19s, peak luma 251), worst
   pixel in the lede's own band:

     plate 0.34 -> (92,94,91)    mist 3.46:1  FAIL   starlight 5.82:1
     plate 0.46 -> (99,99,92)    mist 3.20:1  FAIL   starlight 5.38:1
     plate 0.56 -> (107,104,93)  mist 2.95:1  FAIL   starlight 4.96:1  <-
     plate 0.68 -> (115,110,95)  mist 2.69:1  FAIL   starlight 4.53:1

   Note the first row: at --mist that line was ALREADY failing at the old
   0.34, and had been since this footage replaced the near-black Trence
   plate. It is --starlight now (see .world-lede) and 0.56 is the brightest
   the plate can be while that line still clears 4.5 with a margin. The h2
   is large text and clears 8:1 throughout.

   Re-measure both tables if the loop is ever recut. */
.world-plate { width: 100%; height: 100%; object-fit: cover; opacity: .56; display: block; }
/* The middle band is 46%, down from 55% — see the note on .world-plate. The
   ends stay at full --ink: they are what melts the section into the dark
   sections above and below it, and a seam there would be the first thing
   anyone saw. */
.world-art::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg,
              var(--ink),
              color-mix(in srgb, var(--ink) 46%, transparent) 34%,
              color-mix(in srgb, var(--ink) 46%, transparent) 66%,
              var(--ink));
}
/* Held back on a phone, in the same proportion as before (.24 of .34 was
   70%; this is 70% of .56). The cards stack to one column here and the
   footage has less room to be a picture in, so it goes back to being
   weather. */
@media (max-width: 820px) { .world-plate { opacity: .40; } }

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .scroll-cue svg { animation: none; }
  .reveal { opacity: 1; transform: none; transition: none; }
  .btn, .book-cover-card img { transition: none; }
  .trailer-play, .trailer-play .disc { transition: none; }
  /* The ambient plate never autoplays here — site.js does not start a
     [data-ambient] video under reduced motion, so the poster frame stays
     up and the section still has its picture. Nothing to disable in CSS;
     this note exists so nobody "fixes" it by adding autoplay. */
  #ship { display: none; }
  #voyage .route-wake { stroke-opacity: .3; }
  /* The galaxy holds still (galaxy.js draws one frame and never asks
     for another), but every marker still hovers, taps and takes focus.
     Only the decorative easing goes; the interaction does not. */
  .gp, .gp-dot, .gp-name, .galaxy-readout { transition: none; }
  /* The runway collapse that used to live here is gone: the hero is one
     screen for everybody now that the galaxy has retired. */
  .hero-stage { position: static; height: auto; min-height: 100vh; min-height: 100svh; }
  /* ★ THE HEADER DOES NOT TUCK. Sliding a 68px bar in and out of frame on
     every change of scroll direction is a large, repeated, unrequested
     movement in the reader's peripheral vision — precisely what this
     setting asks to be spared. js/site.js refuses to add the class at all
     here; this is the belt to that braces, and it also covers the moment
     before the script runs. */
  .site-header { transition: none; }
  .site-header.is-tucked { transform: none; }
  /* ★ THE FILM DOES NOT PLAY. The video keeps its `autoplay` attribute so
     that a reader with scripting off still gets the film; here we swap the
     whole element out for the closing card instead. site.js also pauses it
     and drops its source, so the bytes are not fetched either — this rule
     is what makes the swap correct even if that never runs. The card, not
     the opening frame, is the right still: it is the one that carries the
     title and where to buy the book. */
  .hero-video { display: none; }
  .hero-still[hidden] { display: block; }
}

/* ============================================================
   THE CHART RAIL — the route becomes the navigation
   ============================================================
   The ship and her dotted route were already the best thing on this page
   and they were doing no work: pure decoration sailing past six sections
   that could not be reached from it. This is the missing half. A fixed
   rail of the same diamonds voyage.js already draws on the line, always on
   screen, showing where the reader is on the voyage and letting them steer
   to any port.

   THREE RULES, and each one is why it does not become clutter:

   1. ONLY THE ACTIVE LABEL IS SHOWN. A permanent column of six labels is a
      second navigation bar; six diamonds and one word is a chart. The rest
      are one hover away.
   2. IT LIVES IN THE MARGIN THE ROUTE DOES NOT USE. The chart line swings
      to x = 0.85 of the page width, so at 1440 the ship's right edge is at
      1290 and this sits at 1413 — 123px of clear water. The rail is hidden
      below 1024px, where there is no such margin and the header drawer is
      the right answer anyway.
   3. PASSED PORTS STAY LIT. The wake behind the ship is already gold; the
      rail agrees with it, so the two read as one instrument.

   Built by site.js from [data-port-name], which is the same attribute list
   the section markup already carries — one source of truth, and a section
   cannot be added to the page without appearing here. */
.chart {
  position: fixed; right: 18px; top: 50%; transform: translateY(-50%);
  z-index: 9; display: flex; flex-direction: column; align-items: flex-end;
}
/* 1024, not 1023, so the rail and the header's drawer change over on the
   same pixel. One off-by-one was leaving a single width (exactly 1024px)
   showing the hamburger AND the rail at once. */
@media (max-width: 1024px) { .chart { display: none; } }
/* ★ THE HIT TARGET IS 28px WIDE, THE MARK IS STILL 9 (2026-08-14).
   The stop was a 9x31 anchor — under the 24x24 that WCAG 2.5.8 asks of any
   pointer target, and genuinely fiddly with a mouse. The padding is on the
   LEFT only, so the box grows away from the edge of the window and the
   column of diamonds does not move by a pixel: `justify-content: flex-end`
   keeps the mark exactly where it was. */
.chart-stop {
  position: relative; display: flex; align-items: center; justify-content: flex-end;
  padding: 11px 0; width: 9px; text-decoration: none;
  padding-left: 19px; width: 28px;
}
/* The connector between diamonds. right:4px is the centre of the 9px mark,
   and the segment is inset 8px at each end so it meets the diamond's point
   rather than running through it. */
.chart-stop::after {
  content: ""; position: absolute; right: 4px; top: calc(50% + 8px);
  width: 1px; height: calc(100% - 16px);
  /* 24% measured as invisible against the sky at 1px — the diamonds read as
     six loose dots rather than one chart. The line is what makes it a
     route, so it is worth the extra ink. */
  background: color-mix(in srgb, var(--mist) 40%, transparent);
  transition: background .5s ease;
}
.chart-stop:last-child::after { display: none; }
.chart-stop.is-passed::after { background: color-mix(in srgb, var(--gold) 62%, transparent); }
.chart-mark {
  width: 9px; height: 9px; flex: none; transform: rotate(45deg);
  border: 1px solid var(--mist); background: transparent;
  transition: background .45s ease, border-color .45s ease, box-shadow .45s ease, transform .45s var(--ease-out-expo);
}
.chart-stop:hover .chart-mark { border-color: var(--gold-bright); transform: rotate(45deg) scale(1.25); }
.chart-stop.is-passed .chart-mark { background: color-mix(in srgb, var(--gold) 55%, transparent); border-color: var(--gold-deep); }
.chart-stop.is-active .chart-mark {
  background: var(--gold); border-color: var(--gold-bright);
  transform: rotate(45deg) scale(1.35);
  box-shadow: 0 0 10px color-mix(in srgb, var(--gold) 75%, transparent);
}
/* Absolute, so revealing a label never widens the rail and never nudges
   the diamonds sideways — the column of marks has to be a fixed spine or
   the whole thing reads as a menu that twitches. */
.chart-label {
  position: absolute; right: 20px; white-space: nowrap;
  font-family: var(--f-display); font-size: .64rem; font-weight: 600;
  letter-spacing: .18em; text-transform: uppercase; color: var(--mist);
  padding: 4px 9px 3px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--ink) 82%, transparent);
  opacity: 0; transform: translateX(6px); pointer-events: none;
  transition: opacity .3s ease, transform .3s var(--ease-out-expo), color .3s ease;
}
.chart-stop:hover .chart-label,
.chart-stop:focus-visible .chart-label { opacity: 1; transform: none; color: var(--starlight); }
.chart-stop.is-active .chart-label { opacity: 1; transform: none; color: var(--gold-bright); }
.chart-stop:focus-visible { outline: 2px solid var(--gold); outline-offset: 5px; border-radius: 2px; }

/* The header nav agrees with the rail — same active port, same moment.
   The dot is drawn under the word rather than the word being recoloured
   alone: at .78rem uppercase, a colour shift from --mist to --starlight is
   not a strong enough signal to be read as "you are here". */
/* Every anchor on this page lands under the fixed header. The custom glide
   subtracts the same number itself (js/site.js reads --header-h too); this
   is for the native jump — a deep link, a Back button, and readers on
   reduced motion, who do not get the glide.

   ★ WAS A HARDCODED 78px, which was right for one viewport. The header is
   58px on a phone and was 96 on the widths where the nav wrapped, so a deep
   link either left a 20px gap or dropped the section's first line behind
   the bar. --header-h is measured; the +10 is the breathing room the old
   78 was really made of. */
.port, .coda, .hero, [id] { scroll-margin-top: calc(var(--header-h) + 10px); }

/* THE SHIP LIGHTS HER LAMPS WHEN YOU STEER HER. On for the 620ms of a
   glide and nothing else — an engine glow that is always on is a filter,
   and one that comes up only when the reader gives an order is the whole
   difference between decoration and an instrument responding. */
#ship { transition: filter .35s ease; }
body.is-under-way #ship {
  filter: drop-shadow(0 10px 18px rgba(3, 8, 18, .8))
          drop-shadow(0 0 16px color-mix(in srgb, var(--gold) 55%, transparent));
}

.site-nav a { position: relative; }
.site-nav a.is-active { color: var(--starlight); }
.site-nav a.is-active::after {
  content: ""; position: absolute; left: 50%; bottom: 2px; transform: translateX(-50%);
  width: 4px; height: 4px; border-radius: 50%; background: var(--gold);
  box-shadow: 0 0 7px color-mix(in srgb, var(--gold) 80%, transparent);
}
@media (max-width: 1024px) { .site-nav a.is-active::after { bottom: 4px; } }

/* ============================================================
   THE THEATER — pressing play is an event
   ============================================================
   The header fades, a scrim closes over the page, and the frame swells
   toward the middle of the screen. It leaves the same way when the film
   ends, or on Escape, or on a click outside it.

   The frame is moved by TRANSFORM ONLY — translate to the viewport's
   vertical centre, then scale — for three reasons that all matter:
   re-parenting or re-positioning a <video> restarts playback and a
   transform does not; the layout underneath is untouched, so nothing jumps
   and #voyage is never handed a shorter page to bake into its height
   ratchet; and there is no FLIP measurement to get wrong. site.js writes
   --theater-dy and --theater-scale; the geometry is measured, not guessed,
   because the frame's distance from the viewport centre depends entirely
   on where the reader had scrolled to when they pressed play. */
.trailer-slot { max-width: 900px; margin: 0 auto; }
.trailer-frame { transition: transform .6s var(--ease-out-expo), box-shadow .6s ease; }
.theater-scrim {
  position: fixed; inset: 0; z-index: 4;
  background: color-mix(in srgb, #04070E 88%, transparent);
  opacity: 0; transition: opacity .5s ease;
}
.theater-scrim[hidden] { display: none; }
body.is-theater { overflow: hidden; }
body.is-theater .theater-scrim { opacity: 1; }
body.is-theater .site-header { opacity: 0; pointer-events: none; }
/* One transition declaration for this element, not two. The `transform`
   entry is the tuck (see the header block at the top of this file) — it is
   listed here because this rule is later in the cascade and a second
   `transition` up there would simply be overwritten by this one, which is
   exactly how the tuck silently lost its easing the first time.

   NO `will-change: transform`. It would make this element a containing block
   for its own fixed-position drawer, permanently — see the note up there.
   The ground and the blur transition on `.site-header::before`, which is
   where they now live and which is free to be its own layer. */
.site-header {
  transition: transform .34s var(--ease-out-expo),
              box-shadow .4s ease, opacity .45s ease;
}
body.is-theater .trailer-frame {
  position: relative; z-index: 5;
  transform: translateY(var(--theater-dy, 0px)) scale(var(--theater-scale, 1));
  box-shadow: 0 3rem 6rem -1.6rem rgba(0, 0, 0, .95),
              0 0 0 1px color-mix(in srgb, var(--gold) 26%, transparent);
}
.theater-close {
  position: absolute; top: 12px; right: 12px; z-index: 3;
  display: grid; place-items: center; width: 34px; height: 34px; cursor: pointer;
  color: var(--starlight); background: color-mix(in srgb, var(--ink) 66%, transparent);
  border: 1px solid var(--hairline); border-radius: 50%;
  /* Scaled down by the inverse of the frame's own scale so the button is
     the same physical size in the theater as it is out of it — otherwise a
     1.5x frame gives you a 51px close button floating over the film. */
  transform: scale(calc(1 / var(--theater-scale, 1)));
  transform-origin: top right;
  transition: background .25s ease, color .25s ease;
}
.theater-close[hidden] { display: none; }
.theater-close:hover { color: var(--gold-bright); background: color-mix(in srgb, var(--ink) 86%, transparent); }

/* ============================================================
   ATMOSPHERE — the same sky under different weather
   ============================================================
   One fixed pane over the starfield, tinted per port. It is NOT six
   backgrounds: the site has one sky and keeps it. What changes is the air
   in front of it — the film goes darker and colder, Adalasia a shade
   richer, the imprint faintly warm like lamplight on paper, the log almost
   black. The reader never sees a seam, only notices afterwards that the
   place has changed.

   background-color and not a gradient, because gradients do not
   interpolate: a transitioned background-image snaps at the halfway point
   in every engine, which is precisely the seam this is trying to avoid.
   1.6s is long enough that no ordinary scroll can catch it moving.

   It sits inside .sky (z-index 0) so it tints the page ground and the
   stars and never the content — main is z-index 2, above it. */
.zone {
  position: absolute; inset: 0; pointer-events: none;
  background-color: transparent;
  transition: background-color 1.6s ease;
}
body[data-zone="film"]   .zone { background-color: rgba(4, 9, 20, .40); }
body[data-zone="world"]  .zone { background-color: rgba(22, 40, 63, .20); }
body[data-zone="author"] .zone { background-color: rgba(5, 10, 18, .30); }
body[data-zone="press"]  .zone { background-color: rgba(44, 36, 22, .17); }
body[data-zone="log"]    .zone { background-color: rgba(6, 12, 22, .42); }
body[data-zone="coda"]   .zone { background-color: rgba(4, 8, 16, .52); }

/* ============================================================
   THE LAST PORT
   ============================================================ */
/* ★ THE MIN-HEIGHT IS FOR THE PLATE, NOT FOR THE COPY (2026-08-14).
   At its natural height this section was 308px, and `cover` on a 1425x308 box
   keeps only the middle ~38% of a 16:9 frame — a letterbox strip through the
   throne with no picture left in it. Author: "dont squish it like that." The
   copy still occupies a third of a screen and is centred in the box; the extra
   height is headroom for the footage behind it, which is why it is a
   min-height and not padding. At 900px tall this gives ~504px, so the plate
   keeps ~63% of the frame instead of 38%. */
.coda {
  position: relative; z-index: 2; text-align: center;
  padding: clamp(56px, 8vh, 92px) var(--gutter) clamp(74px, 11vh, 130px);
  min-height: clamp(420px, 56vh, 620px);
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
}
.coda-line {
  font-family: var(--f-display); font-weight: 600; color: var(--starlight);
  font-size: clamp(1.45rem, 3.2vw, 2.1rem); letter-spacing: .08em;
  margin: 0 0 30px;
  position: relative;               /* over .coda-art, which is z-index -1 */
}
.coda .cta-row { justify-content: center; position: relative; }

/* ---------- the throne plate, behind the coda ----------
   .coda already carries `position: relative; z-index: 2`, so it is its own
   stacking context and a z-index:-1 child stays inside the section instead of
   sliding under the page. Same construction as .world-art.

   ★ IT MUST STAY TRANSLUCENT, for the same reason the war plate must: main is
   a stacking context, so nothing inside a section can paint BELOW #ship
   whatever z-index it claims. Compositing is by paint order, so the skyship
   sails through the coda dimmed; at 1 she would vanish for the height of the
   section and pop back out under it.

   ★ object-position 50% 36%, and the number is measured. The band is about
   1425x308 on a desktop, so `cover` keeps only the middle ~36% of a 16:9
   frame and the choice of WHICH 36% is the whole composition. At 50% the crop
   lands on the throne and the figure's shoulder — mass with no subject. At 36%
   it takes the head and the glow in the eye-slit, which gives the band the one
   focal point it has, sitting just below the line rather than behind it.

   ★ The opacity is higher than the war plate's and that is not a licence, it
   is compensation: this footage arrives pre-graded and blurred to sigma 14, so
   it carries a fraction of the contrast the raw plate did and needs more
   opacity to reach the eye at all. The war plate's own note records what
   happens when a plate is set too shy to see — 680 KiB nobody could perceive.
   Measured numbers for both live below, next to the values they justify.

   ★★ THE EDGES ARE A MASK ON THE FOOTAGE, NOT INK PAINTED OVER IT, AND THAT
   IS THE WHOLE DIFFERENCE BETWEEN THIS AND THE WAR PLATE. Built first with
   .world-art's scrim copied verbatim — a gradient running to solid var(--ink)
   at both ends — and an A/B with the plate switched off showed the skyship
   GONE for the top third of the section. That is the exact fault .world-plate
   is annotated against; it just never bit there, because #world is tall and
   its opaque ends are a thin sliver of it, while this band is 308px and the
   ends are most of it. The ship rides at viewport centre, so it crosses those
   ends every time.

   So: the footage fades itself out with mask-image, and the scrim now fades
   out at the ends too rather than in. Nothing in this section is ever fully
   opaque, so the ship is dimmed where the picture is and untouched at the
   edges — it can no longer vanish and pop back. The melt into the sections
   above and below is done by the footage going transparent and letting the
   page's own ink through, which reads the same and costs nothing.

   The middle is still no darker for the ship than #world's is, so this is not
   a new licence — it is the same bargain, minus the hole.

   ★★ IT RUNS FULL BLEED, LIKE .world-plate DOES. The footage was masked in
   from the left and right for a while as well, on the theory that a soft-edged
   patch reads as atmosphere. It does not — it reads as a floating blob, and
   the author's note was that it should sit behind the section the way the ship
   footage does. A background is a WALL behind the content, edge to edge; only
   the top and bottom need to melt, because those are the only edges the page
   actually has. So the horizontal mask is gone and only .coda-art's vertical
   one remains.

   ★ That vertical fade is a MASK ON THE FOOTAGE, not ink painted over it, and
   that part is not cosmetic. Built first with .world-art's scrim copied
   verbatim — a gradient running to solid var(--ink) at both ends — and an A/B
   with the plate switched off showed the skyship GONE: 17.4% of its separation
   left, dimmed 131 code values. That is the exact fault .world-plate is
   annotated against; it never bit at #world because that section is tall and
   its opaque ends are a sliver, while this band is 308px and the ends are most
   of it — and the ship rides at viewport centre, so it crosses them every pass.
   Fading the video to TRANSPARENT lets the page's own ink through instead,
   which melts identically and leaves the ship at 65.6%.

   The rest is in the ENCODE, not here: see assets/prep_throneplate.py. The
   plate arrives pre-blurred to sigma 14 and running at half speed, which is
   what makes it recede. ★ Its COLOUR IS THE MASTER'S OWN and must stay that
   way — a navy-and-gold duotone that matched the site palette was tried and
   the author's call was no. */
.coda-art {
  position: absolute; inset: 0; z-index: -1; overflow: hidden;
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 15%,
                                      #000 85%, transparent 100%);
          mask-image: linear-gradient(180deg, transparent 0%, #000 15%,
                                      #000 85%, transparent 100%);
}
.coda-plate {
  width: 100%; height: 100%; object-fit: cover; object-position: 50% 40%;
  opacity: .46; display: block;
}
.coda-art::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg,
              transparent,
              color-mix(in srgb, var(--ink) 45%, transparent) 26%,
              color-mix(in srgb, var(--ink) 45%, transparent) 74%,
              transparent);
}
/* ★ Held back on a phone, but only to .40 — NOT the 70%-of-desktop the war
   plate uses. That proportion was inherited while this plate was still sharp
   footage; blurred to sigma 14 it carries far less contrast, and 70% measured
   as nothing at all on a 375px band — the plate was invisible, which is the
   one outcome the war plate's own note calls worse than not shipping it. */
@media (max-width: 820px) { .coda-plate { opacity: .40; } }

/* ============================================================
   REDUCED MOTION — the second pass, for everything added above
   ============================================================
   Kept separate from the block higher up rather than merged into it: that
   one is the hero's and the galaxy's and is quoted in two other files'
   notes. Later in the cascade, same specificity, so these win. */
@media (prefers-reduced-motion: reduce) {
  /* The tilt is refused in site.js as well; this is the belt to that
     braces, and it also covers the sheen, which is pure decoration. */
  .book-cover-link { transform: none; transition: none; }
  .cover-sheen { display: none; }
  /* .world-note and its image/chip transitions used to be listed here. The
     world cards are static plates now — no hover, no transition, nothing to
     turn off. */
  .chart-mark, .chart-stop::after, .chart-label,
  .log-arrow, .edition-tabs button { transition: none; }
  /* The theater still opens — it is a real function, not an effect — but
     it arrives rather than travels. */
  .trailer-frame, .theater-scrim, .zone { transition: none; }
  /* The plate keeps its colour change on hover — that is the affordance —
     and loses only the 1px lift. */
  .btn::before, .btn::after { transition: none; }
  .btn:hover, .btn:focus-visible, .btn-gold:hover, .btn-ghost:hover { transform: none; }
}

/* ============================================================
   WINDOWS HIGH CONTRAST / forced-colors
   ============================================================
   In forced-colors mode the OS replaces every background and colour with
   its own pair, and it does it to PSEUDO-ELEMENTS too. That matters more
   here than on an ordinary site, because this one draws its buttons, its
   eyebrows and its world cards entirely out of two pseudo-element
   backgrounds inside a clip-path — no border, by design (see the chart
   plate note). Forced colors erase all four of those layers at once, and
   what is left is a word floating on the page with nothing around it: the
   buttons stop looking like buttons.

   So in this mode only, the plate stands down and a real border takes over.
   `ButtonText`/`ButtonBorder`/`Highlight` are system colours — the point is
   to hand the shapes back to the reader's own palette rather than pick new
   ones. The 45-degree chamfer goes with it: a clip-path on a bordered box
   would cut the border away along the cuts, which is the whole reason the
   plate is built out of pseudo-elements in the first place. */
@media (forced-colors: active) {
  .btn, .eyebrow, .world-card, .edition-tabs {
    clip-path: none;
    border: 1px solid ButtonBorder;
    background: ButtonFace;
    color: ButtonText;
    forced-color-adjust: none;
  }
  .btn::before, .btn::after,
  .eyebrow::before, .eyebrow::after,
  .world-card::before, .world-card::after,
  .edition-tabs::before, .edition-tabs::after { display: none; }
  .btn { forced-color-adjust: auto; color: LinkText; }
  .edition-tabs button[aria-selected="true"] { background: Highlight; color: HighlightText; }
  /* Decoration that carries no information and only adds noise once the
     palette is not ours to choose. */
  .sky, #voyage, #ship, .world-art, .coda-art, .book-cover-card::before,
  .author-portrait::before, .author-portrait::after, .imprint-statement .mark { display: none; }
  /* The rail's diamonds are outlines already; they just need a colour that
     exists in this mode. */
  .chart-mark { border-color: CanvasText; }
  .chart-stop.is-passed .chart-mark,
  .chart-stop.is-active .chart-mark { background: Highlight; border-color: Highlight; }
  .chart-stop::after { background: CanvasText; }
  .site-header { border-bottom: 1px solid CanvasText; }
}

/* ============================================================
   PRINT
   ============================================================
   read.css already prints the prose properly, because prose is the thing
   on this site worth printing. This is the other half: if someone prints
   the homepage or the book page — for a shelf-talker, an order form, a
   record of the specs — they should get the words and the cover, on white,
   and not seven screens of navy with a starfield in it.

   Everything that is atmosphere is dropped, every fixed layer is unpinned
   (a `position: fixed` element prints on the FIRST PAGE ONLY and lands on
   top of whatever is there), and links print with their destination so a
   sheet of paper still says where to buy the book. */
@media print {
  html, body {
    background: #fff !important; color: #111 !important;
    overflow: visible !important;
  }
  .sky, #voyage, #ship, .chart, .site-header, .scroll-cue,
  .world-art, .coda-art, .hero-art, .theater-scrim, .signup,
  .imprint-statement .mark, .cover-sheen { display: none !important; }
  .hero, .hero-stage { min-height: 0 !important; }
  main { z-index: auto; }
  .port, .coda { padding: 18pt 0 !important; break-inside: auto; }
  h1, h2, h3, .book-meta .series, .world-card .wc-title,
  .log-row time, .eyebrow, .imprint-statement .sign { color: #111 !important; }
  p, dd, .world-card .wc-body, .log-body p { color: #222 !important; }
  .world-card::before, .world-card::after { display: none; }
  .world-card { border: 1px solid #bbb; }
  .world-notes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .btn { border: 1px solid #444; color: #111 !important; }
  .btn::before, .btn::after { display: none; }
  .reveal { opacity: 1 !important; transform: none !important; }
  video { display: none !important; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 9pt; color: #555; }
  h2, h3 { break-after: avoid; }
  p, li { orphans: 2; widows: 2; }
}
