/* ==========================================================================
   Echoes Editions — Teaser
   Design system locked in DESIGN.md. Values here are not to be improvised:
   colours, spacing and type roles are the brand, not styling preferences.
   ========================================================================== */

/* --- Faces ---------------------------------------------------------------
   Jost     SIL Open Font License. Stands in for Futura PT: letterspaced
            UPPERCASE only — eyebrows, labels, buttons. Never running text.
   Seriffo  Titles and reading body, in place of Artifex CF. One weight (400)
            and no italic file, so the CSS must never ask for bold or italic:
            the browser would synthesise both, and a slanted, smeared serif is
            worse than not having the emphasis at all.
   Optima   Wordmark only, and the wordmark ships as outlined SVG — so no
            Optima file is ever loaded.
   ------------------------------------------------------------------------- */

@font-face {
  font-family: "Jost";
  src: url("../fonts/jost-VF.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Seriffo";
  src: url("../fonts/seriffo-text.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* --- Tokens -------------------------------------------------------------- */

:root {
  /* Colour — never pure #000 or #FFF. Target ratio ~70% ivory/chalk,
     25% ink/graphite/stone, <=5% sienna. */
  --chalk: #f6f4ee;
  --ivory: #ece9e2;
  --stone: #d6d2c9;
  --greige: #8a867c;   /* muted text — 18px and above only, 3.1:1 on ivory */
  --graphite: #494640;
  --ink: #3a3532;
  --sienna: #8a4429;   /* accent only: eyebrows, rules, hover. Never body. */

  /* Spacing — base-8. Nothing off this scale. */
  --s8: 8px;
  --s16: 16px;
  --s24: 24px;
  --s32: 32px;
  --s48: 48px;
  --s64: 64px;
  --s96: 96px;
  --s128: 128px;
  --s160: 160px;

  --font-serif: "Seriffo", Georgia, "Times New Roman", serif;
  --font-label: "Jost", "Century Gothic", "Avenir Next", sans-serif;

  --edge: var(--s24);  /* page gutter, overridden at mobile */

  /* The meta bar is fixed, so it sits over the stage rather than in it and
     the composition above has no idea it is there. This is the band it
     occupies — measured at 61px on desktop, rounded up onto the scale — kept
     as a token so the stage can reserve it instead of guessing. */
  --footer-band: 64px;

  /* Semantic layer. Everything on the page paints from these four, never
     from the raw palette above — which is what lets the whole thing flip to
     ink-on-beige at the end by reassigning them in one place. Sienna is
     deliberately absent: the accent does not invert. */
  --bg: var(--ivory);
  --fg: var(--ink);
  --muted: var(--graphite);
  --rule: var(--stone);
}

/* The end of the page reverses the page. Transitioned rather than switched,
   so arriving at the subscription feels like the light changing. */
html.is-inverted {
  --bg: var(--ink);
  --fg: var(--ivory);
  --muted: var(--stone);
  --rule: var(--greige);
}

/* The subscription screen holds the grain at 0.38 on every breakpoint,
   against the 0.60 / 0.80 used through the rest of the page. Kept as its own
   class rather than folded into .is-inverted: the two happen at the same
   moment today, but they are separate decisions and moving one should not
   silently drag the other with it.

   Lower than the rest of the page, and by more than the old pass allowed,
   because overlay is not symmetric in amplitude: it scales the layer by
   2 * min(L, 1 - L) against the backdrop luminance. Ivory sits at L=0.91 and
   ink at L=0.21, so ink takes roughly 2.4x the modulation from the identical
   plate. Measured on the rendered page, the speckle amplitude over ink is
   2.6/255 here against 1.8/255 over ivory — grain that reads a shade heavier
   in the shadows, which is how film behaves, without returning to the
   blowout that the flat-opacity version produced. Specificity keeps this
   above the mobile :root override, which is deliberate: the value is one
   decision for every screen. */
html.is-cta { --grain: 0.38; }

html,
body,
.narrative,
.screen--hero,
.mark,
.meta-bar a,
.chapter,
.chapter p,
.block__eyebrow,
.edition__title,
.subscribe__input,
.subscribe__submit,
.consent span,
.subscribe__success,
.shot__no {
  transition: background-color 600ms ease, color 600ms ease,
              border-color 600ms ease;
}

/* --- Reset --------------------------------------------------------------- */

*,
*::before,
*::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background-color: var(--bg);
  color: var(--fg);
  font-family: var(--font-serif);
  font-weight: 400;
  font-size: 17px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

h1, p { margin: 0; }
img, svg { display: block; max-width: 100%; }

/* Analog grain.

   Blended, not stacked. The layer used to paint at a flat opacity, which is
   a one-directional effect: mid-grey noise laid over ivory can only darken
   it, and laid over ink it can only lighten it. Ivory sits at 91% luminance
   and ink at 5%, so the same noise was a whisper on the light screens and a
   blowout on the subscription — and raising the opacity made the dark end
   worse before it made the light end visible. mix-blend-mode: overlay fixes
   that: the layer multiplies where the backdrop is dark and screens where it
   is light, so one plate modulates both directions.

   Overlay treats 50% grey as its identity value, which is why the plate is
   built centred on exactly #808080 — a plate averaging 127 or 129 would tint
   the entire page. See tools/make-grain.py, which also documents why the
   plate is not feTurbulence: fractal noise is statistically uniform, and
   uniform noise reads as digital no matter how it is tuned. Real grain
   clumps, varies in size, and leaves dead patches. The plate is 512px and
   painted at 256 — halving it sharpens the speckle and pushes the repeat
   below notice.

   The plate is monochrome where the old one was coloured. Overlay already
   modulates each channel against the backdrop, so the warmth comes from the
   page underneath rather than from the noise — though not with perfect
   neutrality: measured per channel against Ivory (R 0.149 / G 0.173 /
   B 0.227), the speckle runs fractionally cool on the light screens and, by
   the same overlay math, fractionally warm on the dark one. Sub-code-value
   and invisible in practice; left as is.

   Grain and blur are dialled per breakpoint, not globally: a phone screen is
   held closer and its pixels are smaller, so the same blur reads as heavier
   and the same grain reads as lighter than on a desktop display. Hence the
   softer blur and stronger noise under 768px. */
:root {
  --grain: 0.60;
  --blur: 0.35px;
}

body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 200;
  pointer-events: none;
  opacity: var(--grain);
  mix-blend-mode: overlay;
  background-image: url("../img/grain.png");
  background-size: 256px 256px;
}

/* Layer blur, from the Figma frame's Uniform blur.

   Applied to <main> and not to <body>: a filter makes the element a
   containing block for fixed-position descendants, which would tear the
   wordmark and the meta line out of the viewport and dump them into the
   document flow. Both live outside <main> precisely so this can be done. */
main { filter: blur(var(--blur)); }

/* --- Persistent frame ----------------------------------------------------
   The wordmark and the meta line do not scroll away. They are the frame the
   three screens pass through — the "held emptiness" of DESIGN.md §Layout.
   ------------------------------------------------------------------------- */

.brand-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  padding: var(--edge) var(--edge) 0;
  pointer-events: none;
}

/* The wordmark is painted as a mask rather than an <img> so its colour is a
   CSS property. That is the only reason the inversion at the end of the page
   can reach it — an <img> carries its fill baked into the file. */
.mark {
  display: block;
  width: 100%;
  aspect-ratio: 353 / 27;
  background-color: var(--fg);
  -webkit-mask-image: url("../img/wordmark.svg");
  mask-image: url("../img/wordmark.svg");
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  margin-left: auto;
  margin-right: auto;
}

/* The large mark is clipped by JS to the hero's bottom edge: the edge slides
   up over it and the wordmark disappears behind it, rather than shrinking.
   clip-path, not opacity — the point is a hard moving boundary, not a fade. */
.brand-bar--large { z-index: 101; }

/* The small mark is a cross-fade tied to the first chapter, sized to 60% of
   the reading column by narrative.js. Hidden by default so a failed script
   leaves the large one visible rather than nothing at all. */
.brand-bar--small { opacity: 0; }
.brand-bar--small .mark { width: 60%; }

/* Without JS neither wipe nor fade can run, so keep one mark and drop the
   duplicate. */
.no-js .brand-bar--small { display: none; }

.meta-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 100;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--s24);
  padding: var(--edge);
}

.meta-bar__right {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* Eyebrow: the brand's structural layer. Jost caps, wide tracking, never
   lowercase. Graphite not greige — greige fails contrast below 18px. */
.eyebrow {
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.16em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--muted);
}

.meta-bar a {
  color: var(--muted);
  text-decoration: none;
  transition: color 240ms ease;
}

.meta-bar a:hover { color: var(--sienna); }
.meta-bar__sep { color: var(--rule); }

/* The payoff runs as one line. It is a single phrase and a break inside it
   would read as two labels rather than one. */
.meta-bar__left { white-space: nowrap; }

/* --- Screens -------------------------------------------------------------
   svh, not vh: on iOS Safari 100vh includes the address bar and crops the
   bottom of every screen. The @supports block below covers older engines.
   ------------------------------------------------------------------------- */

.screen {
  position: relative;
  width: 100%;
  min-height: 100svh;
  display: flex;
  align-items: center;
  justify-content: center;
}

@supports not (height: 100svh) {
  .screen { min-height: 100vh; }
}

/* Screen 1 — hero */

.screen--hero {
  background-color: var(--bg);
  background-image: url("../img/hero.webp");
  background-size: cover;
  /* Anchored to the bottom edge: the landscape crop is taller than most
     desktop viewports, and centring it cuts the feet off the pieces. */
  background-position: 50% 100%;
  background-repeat: no-repeat;
}

/* The hero photograph is pale and busy exactly where the meta line sits.
   A warm ivory scrim lifts the type without darkening the image —
   the reverse-text rule from DESIGN.md §Color. */
.screen--hero::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 22%;
  pointer-events: none;
  background-image: linear-gradient(
    to top,
    rgba(236, 233, 226, 0.78),
    rgba(236, 233, 226, 0)
  );
}

/* --- The narrative -------------------------------------------------------
   A tall section whose only job is to give the sticky stage room to travel.
   Its height is set by JS from the number of images present, so it is not
   declared here — leaving it unset means a failed script degrades to a
   normal, readable page rather than a blank one.
   ------------------------------------------------------------------------- */

.narrative {
  position: relative;
  background-color: var(--bg);
}

/* The background sits here and not only on .narrative because the stage is
   position: sticky, which makes it a stacking context — and a stacking
   context is where blending stops looking. Without a colour painted inside
   it, the shots would blend against nothing and exclusion would have no
   effect at all. */
.stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  background-color: var(--bg);
}

@supports not (height: 100svh) {
  .stage { height: 100vh; }
}

/* Three layers, and no blend mode at all.

   The brief is that the text turns the background colour wherever a
   photograph passes behind it. No blend mode can do that: every one of them
   computes from the backdrop, so the result changes with the image underneath
   instead of landing on one fixed colour. What produces a true knockout is
   stacking order.

     1  .stage__texts            ink, at the bottom
     2  .stage__shots            the photographs, over it
     3  .stage__texts--knockout  the same words again, in the page colour

   Where no image passes, layer 3 is page-colour type on a page-colour ground
   and is simply not visible, so layer 1 reads as ordinary ink. Where an image
   passes, it hides layer 1 and layer 3 paints the letters back on top of the
   photograph in the ground colour — the type punches a hole through the
   image. The two states are the same two layers seen against different
   middles.

   Layer 3 is cloned from layer 1 by narrative.js rather than written twice in
   the markup: two hand-maintained copies of the manifesto would drift the
   first time a word changed. */
.stage__blend {
  position: absolute;
  inset: 0;
}

/* Text layer, painted last — no z-index needed, it simply follows the shots
   in the DOM. Each chapter is stacked in the same cell and cross-faded. */
.stage__texts {
  position: absolute;
  inset: 0;
  display: grid;
  /* One explicit row filling the box. Without it the row is auto-sized to the
     tallest chapter and each chapter's height: 100% resolves against nothing,
     so "vertically centred" drifted by up to 32px — and drifted by a
     different amount per chapter, depending on how many lines its statement
     ran to.

     minmax(0, ...) and not a plain 1fr, for the same reason the rows inside
     .chapter carry it: a grid row takes an automatic minimum from its
     content, so a plain 1fr grows past the padding rather than being the
     space that is left inside it. That is how the aside came to hang below
     the reserved band and reach the meta bar on a phone — the reserve was
     there, the row simply ignored it. This is what makes the box the box. */
  grid-template-rows: minmax(0, 1fr);
  /* The bottom is deeper than the top by exactly the meta bar's band, and the
     asymmetry is the point.

     This padding used to be symmetric, on the reasoning that the aside hangs
     from its bottom edge and so is held clear of the meta line "without
     needing a second measurement". It isn't: 96px of padding against a 61px
     bar leaves 35px, and because both the aside and the bar are anchored to
     the bottom of the viewport that 35px is constant — it does not open up on
     a taller screen. The aside ran that close to the footer at every size,
     which is what made it read as crowding rather than as composition.

     Reserving the band fixes it, and it also puts the statement where it
     belongs. Centring inside the raw viewport centres it inside a box whose
     bottom strip is permanently covered; centring inside what is actually
     free reads as centred to the eye, which is the only place it is being
     judged. */
  padding: var(--s96) var(--edge) calc(var(--s64) + var(--footer-band));
}

/* The knockout copy. Identical geometry, page colour, and inert: it is a
   visual duplicate, so it must never be read aloud or take a click. */
.stage__texts--knockout {
  pointer-events: none;
  /* Clipped to the photographs by narrative.js. Hidden until then: unclipped
     it covers the ink copy completely and the text reads as blank ground. */
  clip-path: inset(100%);
}

/* With no motion there are no photographs passing, so the second copy has
   nothing to punch through and would only sit on top of the first. */
.narrative.is-static .stage__texts--knockout,
.no-js .stage__texts--knockout {
  display: none;
}

.stage__texts--knockout .chapter__statement,
.stage__texts--knockout .chapter__aside p,
.stage__texts--knockout .chapter__eyebrow {
  color: var(--bg);
}

/* --- Chapter layout ------------------------------------------------------
   Twelve columns across the full stage. The statement holds columns 1-5 and
   sits on the vertical centre; the aside holds 9-11 and hangs from the
   bottom. The two are deliberately not aligned to each other — the gap
   between them is the composition.
   ------------------------------------------------------------------------- */

.chapter {
  grid-area: 1 / 1;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: var(--s24);
  width: 100%;
  height: 100%;
  will-change: opacity, transform;
}

.chapter__statement {
  grid-column: 2 / span 3;
  align-self: center;
  margin: 0;
  font-family: var(--font-serif);
  font-weight: 400;
  font-size: 60px;
  line-height: 1.08;
  letter-spacing: -0.015em;
  color: var(--fg);
}

.chapter__aside {
  grid-column: 9 / span 3;
  align-self: end;
}

/* Scoped through .chapter__aside on purpose. The eyebrow is a <p> inside the
   aside, so the plain .chapter__eyebrow class (0,1,0) loses to
   .chapter__aside p (0,1,1) and the eyebrow silently inherited body size and
   colour. */
.chapter__aside .chapter__eyebrow {
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sienna);
  margin: 0 0 36px;   /* was 24 */
}

/* --- Staged entrance -----------------------------------------------------
   The statement lands first, the eyebrow and body follow half a second
   later. The delay is real time and not scroll distance: scroll decides
   which chapter is current, and the chapter then plays its own two beats at
   its own pace, so the interval reads the same however fast the page moves.
   ------------------------------------------------------------------------- */

.js .chapter__statement,
.js .chapter__aside {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 700ms ease-out, transform 700ms ease-out;
}

.js .chapter.is-active .chapter__statement,
.js .chapter.is-active .chapter__aside {
  opacity: 1;
  transform: none;
}

.js .chapter.is-active .chapter__aside {
  transition-delay: 500ms;
}

/* Image layer, beneath the text. First in the DOM inside the blend group,
   so it is what the text finds behind it. */
.stage__shots {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* top stays 0: the vertical position is the travel itself, written per frame
   as a transform. --x sets the column the image passes through, --w its size.
   --y is left in the markup as a note of the Figma placement but is no longer
   read — nothing is parked at a fixed height any more. */
.shot {
  position: absolute;
  left: var(--x);
  top: 0;
  width: var(--w);
  /* --x is the left edge, so anything wider than the space remaining to the
     right of it would hang off the canvas. This makes that impossible at any
     viewport, which matters most on phones where the widths are scaled up. */
  max-width: calc(96% - var(--x));
  margin: 0;
  opacity: 0;
  will-change: opacity, transform;
}

.shot img {
  width: 100%;
  height: auto;
  box-shadow: 0 8px 40px rgba(58, 53, 50, 0.12);
}

/* The index number beside each image, as drawn in the Figma. Sits just off
   the top-right corner so it reads as an annotation on the plate rather than
   a label inside the photograph. */
.shot__no {
  position: absolute;
  top: 10%;
  left: calc(100% + 6px);
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 10px;
  line-height: 1;
  letter-spacing: 0.08em;
  color: var(--muted);
  white-space: nowrap;
}

/* The photograph behind the subscription. Sits above the stage's solid colour
   and below the subscription's text, and multiplies with the colour rather
   than covering it.

   z-index 2 puts it over .stage__blend, which is where it has to be — the last
   chapter is still dissolving underneath while this rises, and the two share
   the screen for about a viewport of scroll.

   opacity is written per frame by narrative.js on the same curve as the
   subscription, so the two arrive as one move. Setting it here on the same
   element that carries mix-blend-mode is safe: opacity composites the result
   after the blend, it does not isolate the element from its own backdrop. */
.stage__cta-bg {
  position: absolute;
  inset: 0;
  z-index: 2;
  opacity: 0;
  background-image: url("../img/cta-bg.webp");
  background-size: cover;
  background-position: center;
  mix-blend-mode: multiply;
  pointer-events: none;
  will-change: opacity;
}

/* The subscription, rising last. */
.stage__cta {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: var(--s96) var(--s32);
  text-align: center;
  opacity: 0;
  will-change: opacity, transform;
}

.stage__cta .subscribe { width: 100%; max-width: 420px; }
.stage__cta .consent { justify-content: center; text-align: left; }

/* Fallback: reduced motion, or no JS. Everything stacks and reads as a normal
   page. The words are the point; the choreography is not load-bearing. */
.narrative.is-static .stage,
.no-js .narrative .stage {
  position: static;
  height: auto;
  overflow: visible;
}

.narrative.is-static .stage__blend,
.no-js .narrative .stage__blend {
  position: static;
}

.narrative.is-static .stage__texts,
.no-js .narrative .stage__texts {
  position: static;
  display: block;
  /* Stacked and readable, with nothing behind the words to blend against. */
  mix-blend-mode: normal;
}

.narrative.is-static .chapter,
.no-js .narrative .chapter {
  margin: 0 auto var(--s128);
  opacity: 1;
}

.narrative.is-static .stage__shots,
.no-js .narrative .stage__shots {
  position: static;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--s32);
  max-width: 960px;
  margin: 0 auto var(--s128);
}

.narrative.is-static .shot,
.no-js .narrative .shot {
  position: static;
  width: auto;
  opacity: 1;
}

.narrative.is-static .stage__cta,
.no-js .narrative .stage__cta {
  position: static;
  opacity: 1;
  padding-bottom: var(--s160);
}

.block__eyebrow {
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sienna);
  margin-bottom: var(--s32);
}

.chapter__aside p {
  font-size: 16px;
  line-height: 1.55;
  color: var(--fg);
}

.chapter__aside p + p { margin-top: var(--s16); }

.chapter p + p { margin-top: var(--s24); }

.edition__title {
  font-family: var(--font-serif);
  font-weight: 400;
  font-size: 80px;
  line-height: 1.05;
  letter-spacing: -0.015em;
  color: var(--fg);
}

/* Russet on both palettes: the accent is the one thing that does not
   invert with the rest of the page. */
.edition__eyebrow {
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 12px;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--sienna);
  margin-top: var(--s16);
}

/* --- Subscribe ----------------------------------------------------------- */

.subscribe { margin-top: var(--s96); }

/* Desktop carries the invitation inside the field, as drawn. Mobile promotes
   it to a label — see the 767px block. */
.subscribe__label { display: none; }

.subscribe__row {
  display: flex;
  align-items: center;
  gap: var(--s16);
  padding-bottom: var(--s16);
  border-bottom: 1px solid var(--rule);
  transition: border-color 240ms ease;
}

.subscribe__row:focus-within { border-bottom-color: var(--fg); }

.subscribe__input {
  flex: 1;
  min-width: 0;
  padding: 0;
  border: 0;
  background: transparent;
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 12px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--fg);
}

.subscribe__input:focus { outline: none; }
.subscribe__input::placeholder { color: var(--muted); opacity: 1; }

.subscribe__submit {
  flex: none;
  width: 32px;
  height: 20px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--fg);
  cursor: pointer;
  transition: color 240ms ease, transform 240ms ease;
}

.subscribe__submit:hover {
  color: var(--sienna);
  transform: translateX(4px);
}

.subscribe__submit svg { width: 20px; height: auto; }
.subscribe__submit:disabled { opacity: 0.4; cursor: default; transform: none; }

/* Marketing consent is not optional under GDPR, and an address collected
   without it cannot legally be mailed. */
.consent {
  display: flex;
  align-items: flex-start;
  gap: var(--s8);
  margin-top: var(--s16);
  cursor: pointer;
}

/* Aligned to the centre of the FIRST line of the disclaimer, not to the
   block: the text wraps to two lines, so centring against the whole thing
   would leave the box floating between them.

   The offset is derived from the label's own metrics rather than typed as a
   number — font-size here matches the span, so 1.45em resolves to the same
   line box the text uses and the alignment survives a change of type size. */
.consent input {
  flex: none;
  width: 13px;
  height: 13px;
  font-size: 11px;
  margin: calc((1.45em - 13px) / 2) 0 0;
  accent-color: var(--sienna);
}

/* 11px, not 10. Held from the pass where the grain was stacked at a flat
   opacity and its speckle sat inside the counters of small uppercase type.
   Blending the grain instead removed the reason: overlay drives lights lighter
   and darks darker, so it sharpens this line rather than filling it — measured
   on the rendered page, the field-to-glyph contrast goes from 135 to 140 with
   the grain on. The size stays at 11 anyway. This line carries the marketing
   consent, it is the one piece of text that has to survive everything, and
   nothing is gained by taking a point back off it. */
.consent span {
  font-family: var(--font-label);
  font-weight: 400;
  font-size: 11px;
  line-height: 1.45;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.consent a { color: var(--muted); }

.subscribe__note {
  margin-top: var(--s16);
  font-family: var(--font-label);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sienna);
  min-height: 15px;
}

/* Says "check your inbox", not "here is the chapter": with double opt-in on,
   the first email is the confirmation. Promising the chapter would be a lie
   the visitor can measure. */
.subscribe__success {
  display: none;
  margin-top: var(--s96);
  font-size: 14px;
  color: var(--muted);
}

.is-sent .subscribe { display: none; }
.is-sent .subscribe__success { display: block; }

/* --- Motion --------------------------------------------------------------
   The narrative sets opacity and transform inline from the scroll position,
   so there is nothing to transition here: a CSS transition would fight the
   per-frame writes and lag behind the scroll. This block only handles the
   case where the visitor has asked for no motion at all.
   ------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* --- Responsive ---------------------------------------------------------- */

@media (max-width: 767px) {
  :root {
    --edge: var(--s16);
    /* +33% on desktop, and the figure is measured rather than guessed. At
       device-pixel-ratio 3 the 256px tile is drawn across 768 device pixels,
       so the browser interpolates it and the finest octave is averaged away:
       collapsed back to CSS pixels, the same 0.60 renders 1.35/255 of
       speckle against 1.76 on a 1x display. 0.78 restores parity; 0.80 is
       that plus a deliberate touch, since a phone is held at half the
       distance. Judge it on a real handset — this is derived from an
       emulated viewport, which cannot account for the panel itself. */
    --grain: 0.80;
    --blur: 0.25px;  /* halved: the same radius reads far heavier up close */
  }

  /* The portrait crop. Without this the phone downloads the 2560px landscape
     and lets background-size:cover throw most of it away. Back to centre now
     the 9:16 crop matches the shape of a phone viewport — the off-centre
     anchor was compensating for a source that was too wide. */
  /* 686KB of a 3:2 photograph, most of it cropped away by cover on a portrait
     screen, is not worth carrying over a phone connection. */
  .stage__cta-bg { background-image: url("../img/cta-bg-mobile.webp"); }

  .screen--hero {
    background-image: url("../img/hero-mobile.webp");
    background-position: 50% 50%;
  }

  /* --- Chapter on a phone ------------------------------------------------
     Six columns, and the two halves are staggered rather than stacked: the
     statement takes columns 1-4 on the first row, the body columns 3-6 on the
     second. They share columns 3 and 4 horizontally but never the same row,
     so they read as offset without ever colliding — which is what carries the
     desktop's left/right tension onto a screen too narrow to hold it.

     The rows are auto / 1fr: the statement takes exactly its own height at
     the top, the body gets everything left over and hangs from the bottom of
     it. The slack between them is the composition, as on desktop. */
  .chapter {
    grid-template-columns: repeat(6, 1fr);
    /* minmax(0, 1fr) and not plain 1fr: a grid row takes an automatic minimum
       from its content, so a plain 1fr grows past the container instead of
       being the space that is left. This is what caps the second row at the
       stage. */
    grid-template-rows: auto minmax(0, 1fr);
    column-gap: var(--s16);
  }

  .chapter__statement {
    /* Five columns rather than four: at 38px a 223px measure ran the longest
       statement to nine lines, and the rows below had nowhere to go. */
    grid-column: 1 / span 5;
    grid-row: 1;
    align-self: start;
    font-size: clamp(26px, 4.1vh, 38px);
    line-height: 1.05;
  }

  .chapter__aside {
    grid-column: 3 / span 4;
    grid-row: 2;
    align-self: end;
    /* The row is 1fr, so it is exactly the space left under the statement.
       min-height 0 lets this box shrink to it instead of pushing past the
       bottom of the stage, and the overflow is then scoped to the aside
       rather than spilling the last paragraphs under the meta line. */
    min-height: 0;
    max-height: 100%;
    overflow: hidden;
  }

  /* Scoped through .chapter__aside so it outranks .chapter__aside p,
     which is one class plus one type and was quietly winning. */
  .chapter__aside .chapter__eyebrow { font-size: 10px; margin-bottom: var(--s16); }

  /* 11 and not 12, and 1.45 and not 1.5, for one reason: at 12/1.5 the two
     longest chapters do not fit the phone and lose their closing paragraph —
     III its "the intention is resolved", IV its last line. The box clips, so
     the loss is silent and lands mid-sentence. The 13% this recovers is what
     the longest chapter is over by.

     It buys the spacing too. The aside hangs from the bottom of its row, so
     while its text overflows it fills the row and its eyebrow arrives hard
     against the statement above. Text that fits sits lower than the row it is
     in, and the gap under the statement comes back on its own. */
  .chapter__aside p { font-size: 11px; line-height: 1.45; }
  .chapter__aside p + p { margin-top: var(--s8); }

  /* Symmetric here, and the desktop's reserved band deliberately not taken.

     The phone's bar is two rows and sits lower in the eye's field, so the
     desktop reserve reads as a hole rather than a margin: it left 88px of
     visible white under the last line against the 50px set between the
     wordmark and the statement, which is the one interval on this screen
     there is to compare it against. 96 brings that to 56 — near enough to
     read as the same interval — and still clears the bar by 38.

     This is the value the rule always had. It was never the wrong number;
     it was the row above it not holding its box. */
  .stage__texts { padding: var(--s96) var(--edge); }

  .stage__cta { padding: var(--s64) var(--s32); }
  .subscribe { margin-top: var(--s48); }
  .subscribe__success { margin-top: var(--s48); }

  /* The scatter is tuned for a wide canvas, so on a phone every shot is
     scaled up — 2.21 is the old 1.7 plus 30%. min-width lifts only the
     smallest plates, which were arriving too slight to register; the cap
     stays exactly where it was, so nothing grows at the top end.

     The floor is itself capped by the space left to the right of --x, because
     min-width beats max-width in the cascade: a bare 24% floor overrides the
     .shot max-width and pushes any plate placed past x:72% out to the screen
     edge, taking its index number off-screen with it. Written as min() so the
     floor can never ask for room the plate does not have. */
  /* A plate falls back to its desktop column, scaled up, and only the ones
     given an explicit --xm / --wm depart from it. So the phone stays one
     design with exceptions rather than a second list of twenty-four values to
     keep in step with the first.

     Where --wm is set it also becomes the min-width, which cancels the floor:
     the floor lifts plates that came out too slight from the desktop share,
     and a width chosen for the phone directly has already had that judgement
     made. The arranger clamps --wm to the room left of 96%, and
     tools/check-plates.py fails on anything that runs past it. */
  .shot {
    left: var(--xm, var(--x));
    width: var(--wm, calc(var(--w) * 2.21));
    max-width: calc(96% - var(--xm, var(--x)));
    min-width: var(--wm, min(24%, calc(96% - var(--xm, var(--x)))));
  }

  /* A plate that runs to the 96% cap leaves 4% of a phone screen — about
     15px — for its index number, and 6px of that went to the offset alone,
     shaving the full stop off every right-hand plate. The number is the
     annotation, not the margin: it gives up the 3px instead. */
  .shot__no { left: calc(100% + 3px); }

  /* The subscription box, tightened. */
  /* Scoped to .stage__cta to outrank the 420px cap set on the same element
     outside this media query — specificity beats source order, so a bare
     .subscribe here would silently lose. */
  .stage__cta .subscribe {
    max-width: 260px;
    margin-left: auto;
    margin-right: auto;
    margin-top: 0;
    padding-top: 60px;
  }

  .edition__title { font-size: 60px; }
  .edition__eyebrow { margin-top: 5px; }

  .subscribe__label {
    display: block;
    margin-bottom: var(--s8);
    font-family: var(--font-label);
    font-weight: 500;
    font-size: 12px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--muted);
  }

  /* 10px is a deliberate choice, not an oversight: iOS Safari zooms the
     whole page when a focused input is under 16px, so tapping this field
     will nudge the layout on an iPhone. Raise it back to 16px to stop that. */
  .subscribe__input {
    font-size: 10px;
    letter-spacing: 0.06em;
  }

  .subscribe__row { padding-bottom: var(--s8); gap: var(--s8); }
  .subscribe__submit { width: 24px; }
  .subscribe__submit svg { width: 16px; }
  .consent span { font-size: 10px; line-height: 1.4; }
  .consent input { width: 12px; height: 12px; font-size: 10px; margin-top: calc((1.4em - 12px) / 2); }
}

/* --- Short desktop viewports ---------------------------------------------

   The stage is a 100svh box and the meta bar is fixed on top of it, so
   nothing in the composition knows the footer is there. On a tall display the
   slack absorbs it. On a laptop the two meet.

   Measured at 1470px wide: the aside stops 35px above the footer at every
   height, and by 700px it has crossed it and run past the bottom of the
   screen entirely. The statement goes through the footer at the same height,
   which is the same defect wearing the other column.

   Reserving the meta bar's band, which .stage__texts now does at every size,
   is what stops the two touching. It cannot stop them overlapping: below
   roughly 820px the longest aside is simply taller than the room left for it,
   and a block that does not fit crosses whatever is beneath it. So this step
   makes the text need less height.

   1. The aside takes a fourth column. It grows leftwards, from 8 rather than
      out to 12, so the outer margin on the right is exactly where it was. At
      three columns the measure runs about 42 characters — well under the
      65-75 the system asks for — so the fourth column shortens the block and
      corrects the measure with one change.
   2. The statement drops one step. 60px is a size for a full-height screen.

   Width is the second route to the same failure and needs the same answer. A
   narrow window does not reduce the room, it makes the same text taller: at
   1024x768 — an iPad held sideways — the three-column aside runs 647px into
   544px of space. So the condition is short OR narrow, not short alone.

   Bounded by min-width so it cannot reach the phone layout, which solves this
   its own way by shrinking the type.
   ------------------------------------------------------------------------- */
@media (min-width: 768px) and (max-height: 900px),
       (min-width: 768px) and (max-width: 1200px) {
  .chapter__aside { grid-column: 8 / span 4; }

  .chapter__statement { font-size: 48px; }
}

/* A second step, for a window dragged genuinely short. The fourth column and
   the smaller statement carry the layout down to about 700px; below that the
   longest aside — III, six paragraphs — reaches the meta bar again, because
   the block is bottom-anchored and simply has more text than there is room.
   Width cannot help twice, so this step takes it out of the vertical: one
   size off the body, the eyebrow's 36px lead back to the 24 it grew from, and
   the paragraph gap halved. Together they lift the longest chapter clear
   without touching the wider composition.

   Short or narrow again, at the tighter pair of thresholds. */
@media (min-width: 768px) and (max-height: 700px),
       (min-width: 768px) and (max-width: 1100px) {
  .chapter__statement { font-size: 40px; }

  .chapter__aside .chapter__eyebrow { margin-bottom: var(--s24); }

  .chapter__aside p { font-size: 15px; line-height: 1.5; }
  .chapter__aside p + p { margin-top: var(--s8); }
}

/* The head padding is the last thing to give, and only to height. 96px above
   the composition is generous on a full screen and simply expensive on a
   short one, where every pixel of it is taken from the text below. Height
   only: a narrow window has no shortage of vertical room and gains nothing
   from losing its top margin. */
@media (min-width: 768px) and (max-height: 700px) {
  .stage__texts { padding-top: var(--s48); }
}

@media (max-width: 767px) {
  /* The two links stack in the bottom-right corner, Instagram above the
     address. column-reverse rather than reordering the markup: the source
     order stays email-then-Instagram, which is the order a screen reader
     should read them in and the order the desktop line uses. */
  .meta-bar__right {
    flex-direction: column-reverse;
    align-items: flex-end;
    gap: 4px;
  }

  .meta-bar__sep { display: none; }
}

@media (max-width: 479px) {
  .meta-bar { font-size: 9px; }
  .eyebrow { font-size: 9px; letter-spacing: 0.1em; }
}
