/* ==========================================================================
   PowerPod Platform — sitewide stylesheet
   Shared by every page. Sections:
     1. Font face
     2. Tokens
     3. Reset & base
     4. Glaze system (ceramic background)
     5. Film & scrim
     6. Grain
     7. Typography
     8. Reel & chapters (scroll-driven narrative)
     9. Chapter typography
    10. Closing footer
    11. Mobile & landscape
    12. Performance tier
    13. Input guard
    14. The station index
    15. Get in touch

   Stacking order, bottom to top:
     0  .glaze     ceramic surface
     1  #film      the WebGL drawing
     2  .scrim     reads the drawing off the lower third
     3  .reel      the copy, and the only layer anyone can touch
     5  .index     station ticks
     6  .touch-layer  Get in touch sheet
     7  .grain     one clay tooth over all of it, which is what fuses them
   ========================================================================== */


/* 1. Font face ============================================================ */
/* DM Sans variable — weight axis 100–1000, optical size axis 9–40.
   Self-hosted: no third-party request, renders offline, no CDN dependency. */

@font-face {
  font-family: 'DM Sans';
  src: url('../fonts/DMSans-Variable-latin.woff2') format('woff2');
  font-weight: 100 1000;
  font-style: normal;
  font-display: swap;
  /* Latin subset — covers U+2122 (™) used in the wordmark. */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
                 U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
                 U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}


/* 2. Tokens =============================================================== */

:root {
  /* Surface */
  --porcelain: #faf8f5;   /* warm off-white clay body */
  --ink:       #0a0a0a;   /* near-black; pure #000 vibrates on warm white */

  /* Glaze hues — all held at ~91–92% lightness, 38–60% saturation.
     Pushing lightness below ~90% or saturation past ~65% tips the effect out
     of fired glaze and into generic gradient mesh. */
  --glaze-blush:      hsl(14 55% 92%);
  --glaze-celadon:    hsl(158 38% 91%);
  --glaze-periwinkle: hsl(222 50% 92%);
  --glaze-clay:       hsl(38 60% 92%);

  /* Glaze geometry.
     Blur scales with the viewport rather than sitting at a flat 70px: the
     effect is proportionally identical on a small screen and materially
     cheaper to fill, which matters once the reel stacks promoted layers on
     top of it. */
  --glaze-blur: clamp(40px, 6vmax, 70px);
  --glaze-inset: 20vmax;   /* container overhang past the viewport */
  --blob-size: 85vmax;
  --blob-offset: calc(var(--blob-size) / -2);

  /* Type */
  --font-sans: 'DM Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
  /* Floor is set so the wordmark still fits on one line at 360px — the
     lockup reading as a single line matters more than raw size on phones. */
  --step-display: clamp(2rem, 8.5vw, 7rem);
  --step-title:   clamp(1.75rem, 6vw, 4.5rem);
  /* Band chapters share the screen with the drawing, so their titles are
     sized to a caption rather than to a full-frame statement. */
  --step-title-band: clamp(1.5rem, 3.4vw, 2.9rem);
  --step-lede:    clamp(1.05rem, 1.9vw, 1.6rem);
  --step-body:    clamp(0.95rem, 1.2vw, 1.125rem);
  --measure: 34ch;

  /* The Get in touch card. Its size is a token rather than a consequence of
     its contents because the morph in section 15 has to know the rectangle it
     is growing into before the schema that fills it has even arrived — and
     because a card that changed shape between questions would undo the one
     thing asking them one at a time is for. Every question is sized to fit
     this box; see --step-ask. */
  --card-w: 34rem;
  --card-h: 34rem;
  --card-pad: clamp(1.4rem, 3.2vw, 2rem);
  --card-radius: 1.25rem;
  /* Read by scripts/touch.js off .touch-plate, so the growing rectangle and
     the landed card are the same fill by construction.
     OPAQUE, and there is no backdrop-filter anywhere in section 15. A
     translucent card over a blurred backdrop is one full re-blur of a
     card-sized region on every frame the backdrop changes — and the glaze
     underneath changes on every frame of its 29-second drift, so that bill
     ran for as long as the card was open. This is a flat fill: one composite,
     no filter, nothing to recompute. */
  --card-fill: color-mix(in srgb, var(--porcelain) 92%, white);
  /* Between a lede and a band title: the question is the statement on screen,
     but the longest of them runs to eighty characters and still has to sit in
     the card above its answers. */
  --step-ask: clamp(1.2rem, 2.1vw, 1.55rem);

  /* Reel geometry.
     Every unit here is `svh`, never `dvh`. These values set where each chapter
     begins and ends along the scroll; `dvh` changes as the mobile URL bar
     collapses, which would re-time the whole sequence mid-scroll and make
     sections visibly jump. `svh` is fixed for the life of the page. */
  /* Thirteen chapters at 200svh comes to about 26 screens, which is what the
     film was cut for. It was 280 when there were six chapters and nothing to
     watch; a beat now plays under every one of them, so a shorter chapter
     reads as better paced rather than rushed. */
  --chapter-span: 200svh;    /* scroll distance one chapter owns */

  /* The hero is a title card, not a chapter, and it was being charged a
     chapter's rent. Nothing happens in it: ANCHORS in scripts/scroll.js holds
     the film between t 0.000 and 0.008 for its entire length, and hero-life
     below holds the wordmark at full opacity with no arrival beat to play.
     A full 200svh of that made the first thing anyone does on this page the
     least rewarding thing on it.

     It gets its own, shorter span, and every chapter after it is pulled up by
     the difference. One constant lead rather than a per-chapter span table:
     that is all this particular problem needs, and the general case would
     mean cumulative offsets threaded through three files. --reel-lead is
     negative, and everything downstream simply adds it. */
  --hero-span: 70svh;
  --reel-lead: calc(var(--hero-span) - var(--chapter-span));
  /* Range widening at both ends. Derived from the span rather than set
     independently, so the handoff timing holds at every breakpoint and only
     --chapter-span is retuned below.

     Deliberately small. A wide overlap sounds like the way to get a smooth
     crossfade, but for two titles centred in the same place it cannot work:
     with a symmetric fade, the opacity at which they cross is the *same*
     number as the lowest opacity reached, so buying continuity always buys an
     equal amount of double-exposure. Measured at 7%, both titles sat at 0.37
     on top of each other and read as a printing error.

     At 2% the successor starts at 96.2% — after this chapter is already gone.
     Nothing is ever superimposed, at the cost of roughly 0.1 of a screen where
     the ceramic shows through alone. That reads as a deliberate breath. */
  --chapter-overlap: calc(var(--chapter-span) * 0.02);

  /* Choreography. Retuned per breakpoint by overriding these, rather than by
     writing a second set of keyframes. */
  --scale-arrive: 1.35;   /* title size on arrival, relative to settled */
  /* Pushed well past 1 on purpose: at the crossing point the outgoing chapter
     is much larger than the incoming one, so the eye reads the two as
     separated in depth rather than collided in the same plane. */
  --scale-depart: 1.6;

  color-scheme: light;
}


/* 3. Reset & base ========================================================= */

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

html {
  -webkit-text-size-adjust: 100%;
  /* Covers whatever this page scrolls programmatically outside scripts/
     scroll.js's own hand-tweened glides — a fragment jump, `:target`, a
     future `scrollIntoView()` — with the browser's own easing. It does NOT
     cover wheel/touch: per spec, `scroll-behavior` only ever governs a
     scroll a script or navigation asks for, never one the user is driving
     directly with an input device, so a plain mouse's chunky wheel notches
     would land as instant jumps regardless of this rule. scripts/scroll.js
     smooths those itself (see WHEEL SMOOTHING there) precisely because this
     property cannot. */
  scroll-behavior: smooth;
  /* Get in touch locks the scroll with `overflow: hidden` while the card is
     open (section 15). Where scrollbars take real width, that would otherwise
     hand back 15px of viewport on the way in and take it away again on the
     way out, sliding the whole page sideways under a card that is in the
     middle of growing out of one of its own pixels. Reserved once, so neither
     statement can move anything. No effect at all where scrollbars overlay. */
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  min-height: 100svh;
  background-color: var(--porcelain);
  color: var(--ink);
  font-family: var(--font-sans);
  font-optical-sizing: auto;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* `clip`, NOT `hidden`. `overflow-x: hidden` makes the body a scroll
     container, which silently breaks `position: sticky` on every descendant —
     it would defeat the entire reel. `clip` clips identically without
     establishing a scroll container. */
  overflow-x: clip;
  /* Keep pull-to-refresh from firing part-way through the narrative. */
  overscroll-behavior-y: contain;
}


/* 4. Glaze system ========================================================= */
/* Four oversized, heavily blurred colour fields, held still.
   
   This used to drift: each blob ran two animations on mutually prime periods
   (29/37/43/53s for position, 31–59s for opacity, 61s for the sheen) so the
   colour mix at any fixed point kept shifting. It was the best thing on the
   page and it was also, by a wide margin, the most expensive: a blur whose
   INPUT moves can never be cached, so this layer was re-rasterised on every
   frame, on every page, forever — including while the page sat idle and
   nobody was looking at it.
   
   Held still, the whole glaze collapses to one rasterisation that is reused
   for the life of the page and the per-frame cost of the background goes to
   zero. Nothing is removed, shrunk, softened or flattened: same four blobs,
   same blur radius, same colours, same oversized container, same sheen. It is
   the same picture. It simply is not a moving one.
   
   `will-change` had to go with the animations, and that half is not optional:
   it is what keeps each blob on its own composited layer, and five promoted
   layers feeding a filter is precisely what stops the result being cacheable.
   Freezing the picture and leaving will-change on would hold it still and keep
   paying for it. If any animation ever comes back here, both halves come back
   together and so does the bill.
   
   No opacity is declared on the blobs, so every one of them sits at 1 rather
   than part way through a breathe cycle. That is the most saturated the glaze
   ever got, and it is deliberate — this is the composition now, not a frame
   sampled out of a loop.
   
   The container is inset past the viewport on every side so the blur's own
   falloff at the container edge happens off-screen; otherwise it would show
   as a soft vignette of bare base colour. Those offsets are in vh/vw/vmax,
   which are the LARGE viewport units: they do not move when a mobile URL bar
   collapses, so the cached raster survives a scroll on a phone. (`dvh` would
   not, and must never appear here.)

   Blur is applied once here rather than per blob: one rasterisation, not four. */

.glaze {
  position: fixed;
  inset: calc(var(--glaze-inset) * -1);
  z-index: 0;
  overflow: hidden;
  filter: blur(var(--glaze-blur));
  pointer-events: none;
  contain: paint;
}

.glaze__blob {
  position: absolute;
  width: var(--blob-size);
  height: var(--blob-size);
  margin-top: var(--blob-offset);
  margin-left: var(--blob-offset);
  border-radius: 50%;
}

/* Centres are given in viewport units offset by the container overhang, not
   in percentages: percentages here would resolve against the oversized
   container, so intended placement would drift as the viewport changed. */

.glaze__blob--blush {
  top: calc(var(--glaze-inset) + 26vh);
  left: calc(var(--glaze-inset) + 22vw);
  background: radial-gradient(closest-side, var(--glaze-blush), transparent);
}

.glaze__blob--celadon {
  top: calc(var(--glaze-inset) + 20vh);
  left: calc(var(--glaze-inset) + 78vw);
  background: radial-gradient(closest-side, var(--glaze-celadon), transparent);
}

.glaze__blob--periwinkle {
  top: calc(var(--glaze-inset) + 78vh);
  left: calc(var(--glaze-inset) + 26vw);
  background: radial-gradient(closest-side, var(--glaze-periwinkle), transparent);
}

.glaze__blob--clay {
  top: calc(var(--glaze-inset) + 74vh);
  left: calc(var(--glaze-inset) + 76vw);
  background: radial-gradient(closest-side, var(--glaze-clay), transparent);
}

/* Broad specular highlight — the wet look of a fired glaze.
   Inset back to the true viewport box so its gradient stops line up with
   what the visitor actually sees. */
.glaze__sheen {
  position: absolute;
  inset: var(--glaze-inset);
  /* Kept well under full white: at high opacity this bleaches the upper half
     of the viewport and the glaze underneath stops reading. */
  background: radial-gradient(115% 70% at 50% 6%,
              rgba(255, 255, 255, 0.5), transparent 66%);
}

/* 5. Film & scrim ========================================================= */
/* scripts/scroll.js mounts a transparent WebGL canvas in #film and puts
   .film-live on <html>. Everything that only makes sense with a drawing
   behind it hangs off that class, so a browser without WebGL and a page with
   JavaScript off both get the plain centred reel on bare ceramic instead of
   a layout arranged around something that is not there.

   The canvas clears to transparent and its occluder meshes are painted in
   --porcelain, so the pod reads as an object of the page's own material
   lying on the glaze rather than a white cut-out. Opacity is written by the
   film itself, once per frame, and carries both its own dissolves and the
   hero's blueprint dim. */

#film {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  /* No transition: the film sets this every frame from scroll position, and
     any easing here would fight the scroll for control of it. */
  opacity: 0;
}

/* Sized here rather than by three.js, which is handed updateStyle=false in
   scripts/cinema.js. The backing store is allowed to lag a mobile URL bar by
   a beat (see RESIZE in scripts/scroll.js); the element it is presented in
   must not, or that lag shows as a strip of bare glaze along the bottom. A
   briefly stale buffer stretched over the right box is invisible; a correct
   buffer in the wrong box is not. */
#film canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* Lifts the copy off the drawing without boxing it in. The band is a wash of
   the page's own porcelain rather than a panel, so the ceramic keeps reading
   through it and there is never an edge to notice. */
.scrim {
  position: fixed;
  inset: auto 0 0 0;
  height: 48svh;
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
  background: linear-gradient(to top,
              rgba(250, 248, 245, 0.94) 0%,
              rgba(250, 248, 245, 0.72) 34%,
              rgba(250, 248, 245, 0.28) 68%,
              transparent 100%);
}

.film-live .scrim { opacity: 1; }

/* No backdrop-filter here on purpose. Frosting the wireframe behind the copy
   is the nicer read in a still frame, but it costs a full-viewport backdrop
   pass on every frame the canvas changes, which is every frame of a scroll.
   The camera already lifts its subject clear of this band, so the wash has
   very little left to hide and the blur would be paying for almost nothing. */


/* 6. Grain ================================================================ */
/* Fine clay tooth, via an inline feTurbulence SVG. This layer sits OUTSIDE
   .glaze — running it through the blur would erase the very texture that
   separates "ceramic" from "blurry gradient".
   feColorMatrix saturate=0 is required: feTurbulence writes noise into all
   four channels, so without it the grain comes out coloured. */

.grain {
  position: fixed;
  inset: 0;
  /* Above the canvas, not below it: the same tooth over the drawing and the
     glaze alike is what makes them read as one surface rather than a render
     sitting on a background. z-index 7 puts it over the Get in touch sheet
     as well, so the card is clay, not a pane floating on top of clay. */
  z-index: 7;
  pointer-events: none;
  opacity: 0.05;
  /* NO mix-blend-mode, and this is the single most important line in the file
     for how hot the machine gets.

     It used to be `multiply`. A blend mode on a full-viewport element at the
     top of the stack cannot be composited on its own: the GPU needs the
     backdrop as a readable texture, so the compositor flattens EVERYTHING
     beneath this element into one buffer, reads it back, blends, and writes
     out. That erases the point of every layer underneath — the film's canvas
     and the thirty-odd scroll-driven copy animations are on their own layers
     precisely so they can move without repainting each other, and this forced
     all of them merged on every frame any one of them changed, which during a
     scroll is every frame. At DPR 2 on a laptop display that is around eight
     million pixels read, blended and written per frame, on top of the film's
     own render at the same resolution.

     It is bandwidth, not shader work, which is why a fast GPU did not help:
     on shared-memory hardware it saturates the bus the CPU is also using, and
     that is what spins the fan.

     The cost of dropping it is one thing and only one thing. Over porcelain
     and over the glaze the two modes are the same arithmetic at 5% opacity —
     mid-grey noise over porcelain lands on 243.8/255 under multiply and
     243.9/255 without it. They part only over the film's own ink lines, where
     multiply against black stays black and a plain composite lifts a 1px line
     from #000000 to about #060606. That is the whole of what was traded. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 240px 240px;
}


/* 7. Typography =========================================================== */

.wordmark {
  margin: 0;
  font-size: var(--step-display);
  font-weight: 500;
  /* Tight tracking is what makes display type read as drawn rather than
     as body type scaled up. */
  letter-spacing: -0.035em;
  line-height: 1.05;
  text-align: center;
  text-wrap: balance;
}

.wordmark__tm {
  /* Both knobs in one place — this rule is the only definition of the mark,
     so it applies everywhere ™ appears (hero wordmark, closing footer, and
     any future page using .wordmark__tm). */
  --tm-size: 0.32;   /* mark size, as a fraction of the wordmark's font-size */
  --tm-cap: 0.7;     /* DM Sans cap height, in em */

  font-size: calc(var(--tm-size) * 1em);
  font-weight: 500;

  /* Aligns the top of the ™ with the top of the P rather than guessing an
     offset. `vertical-align` lengths resolve against the element's OWN
     font-size, so raising the mark's top to the parent's cap height needs
     cap × (1 − size) / size in child em — here 0.7 × 0.68 / 0.32 ≈ 1.49em.
     Deriving it means changing --tm-size alone keeps the alignment correct.
     (`vertical-align: super` uses the font's superscript metric, which sits
     far below cap height at this size.) */
  vertical-align: calc(
    var(--tm-cap) * (1 - var(--tm-size)) / var(--tm-size) * 1em
  );

  letter-spacing: 0;
  margin-left: 0.06em;
}


/* 8. Reel & chapters ====================================================== */
/* One sticky stage holds every chapter in the same grid cell, so they
   crossfade in place rather than following one another.

   The obvious alternative — giving each chapter its own tall sticky block —
   leaves an unavoidable ~100svh gap: a chapter's sticky child unpins exactly
   when the next chapter's top is still a full viewport below, so the outgoing
   chapter has finished fading before the incoming one starts. Stacking them
   removes the gap entirely and makes the overlap tunable. */

.reel {
  position: relative;
  z-index: 4;
  /* Scrollable distance inside the reel comes to count·span + overlap, which
     is exactly where the last chapter's range ends — so the final departure
     completes at the same moment the stage unpins. */
  height: calc(var(--chapter-count) * var(--chapter-span)
               + var(--reel-lead)
               + var(--chapter-overlap) + 100svh);
}

.reel__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  display: grid;
  place-items: center;
  /* `clip` not `hidden`: departing chapters scale past the viewport edges and
     must be trimmed, but this element must not become a scroll container. */
  overflow: clip;
}

/* Zero-width probe. Lets the fallback scrubber read the resolved pixel value
   of --chapter-span without hard-coding the number in JS, so the two can
   never drift apart. */
.reel__metric {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: var(--chapter-span);
  visibility: hidden;
  pointer-events: none;
}

/* The hero's span is its own number now, so it needs its own probe — same
   contract as the one above, and for the same reason: the svh arithmetic
   stays in CSS and no driver has to hard-code a figure that could drift. */
.reel__metric--hero { height: var(--hero-span); }

.chapter {
  grid-area: 1 / 1;
  width: 100%;
  display: grid;
  place-items: center;
  padding-block: calc(2rem + env(safe-area-inset-top))
                 calc(2rem + env(safe-area-inset-bottom));
  padding-inline: calc(1.5rem + env(safe-area-inset-left))
                  calc(1.5rem + env(safe-area-inset-right));
  /* Inert by default. A transparent chapter still receives touches, and six
     of them share one grid cell — without this the topmost would swallow
     every tap on the page. The keyframes re-enable it only while readable. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  animation: chapter-life linear both;
}

.chapter--hero { animation-name: hero-life; }
.chapter--last { animation-name: chapter-arrive-hold; }

/* These bindings live here, next to .chapter, and NOT beside the type rules in
   section 8 — because the `animation` shorthand resets animation-timeline and
   animation-range to their initial values. Declared after the @supports block
   below, it would silently strip the scroll timeline off the title and body,
   leaving them fill-frozen at their end state: the title never arriving
   oversized, the body copy visible from the first frame. Any later rule using
   the shorthand on these elements reintroduces that bug. */
.chapter__title { animation: title-settle linear both; }
.chapter__body  { animation: body-reveal linear both; }

.chapter__inner {
  display: grid;
  justify-items: center;
  gap: clamp(1.1rem, 2.4vw, 1.9rem);
  width: 100%;
  max-width: 62rem;
  text-align: center;
}

/* Band chapters ------------------------------------------------------------
   With a film behind it, copy centred in the frame sits on top of the very
   drawing it is describing. Every chapter that has a beat to itself moves to
   a band across the bottom instead, and the camera lifts its subject into the
   clear two thirds above (scripts/scroll.js hands the film that lift). The
   hero and the closing roster keep the centre: neither has a subject to make
   room for.

   The band is the same on a phone as on a desktop. It is the one arrangement
   that does not need the film re-framed per breakpoint, and the film is the
   expensive thing to re-frame. */

.film-live .chapter--band {
  align-self: stretch;
  align-items: end;
  padding-bottom: clamp(1.75rem, 5svh, 3.5rem);
  /* Growing from the bottom edge, so the departure expands up into the frame
     instead of pushing the last line off the bottom of the screen. */
  transform-origin: 50% 100%;
  /* Gentler than the centred chapters: a band this close to the viewport
     edge has nowhere to grow sideways. */
  --scale-arrive: 1.16;
  --scale-depart: 1.35;
}

.film-live .chapter--band .chapter__inner {
  max-width: 46rem;
  gap: clamp(0.6rem, 1.6vw, 1.05rem);
}

.film-live .chapter--band .chapter__title {
  font-size: var(--step-title-band);
}

/* Native path — no JavaScript involved. Chapter i owns
   [i·span, (i+1)·span], widened by the overlap at both ends so adjacent
   chapters crossfade instead of handing over abruptly.

   Offsets are absolute rather than percentages: percentages resolve against
   total document scroll, so every chapter's timing would shift whenever the
   page's length changed. --i is inherited, so the title and body pick up the
   same window as their chapter. */

@supports (animation-timeline: scroll(root block)) {
  .chapter,
  .chapter__title,
  .chapter__body {
    animation-timeline: scroll(root block);
    animation-range:
      calc(var(--i) * var(--chapter-span) + var(--reel-lead)
           - var(--chapter-overlap))
      calc((var(--i) + 1) * var(--chapter-span) + var(--reel-lead)
           + var(--chapter-overlap));
  }

  /* The hero is already on screen at rest, so it has no arrival beat and its
     range must start at 0 — a negative start would leave it part-faded at
     scroll position zero. */
  .chapter--hero {
    animation-range: 0 calc(var(--hero-span) + var(--chapter-overlap));
  }
}

/* Fallback path. Default to a plain stacked document: a visitor whose browser
   lacks scroll timelines AND has JavaScript disabled must get readable
   content, never a blank page. scripts/reel.js adds .reel-scrub to <html>
   only once it is actually able to drive the sequence. */

@supports not (animation-timeline: scroll(root block)) {
  .reel { height: auto; }

  .reel__stage {
    position: static;
    height: auto;
    display: block;
    overflow: visible;
  }

  .chapter {
    min-height: 100svh;
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    animation: none;
  }

  .chapter__title,
  .chapter__body { animation: none; }

  /* JS is present and driving: restore the reel and hand it paused
     animations to scrub. The @keyframes are the same ones the native path
     uses, so the two drivers cannot diverge. */
  .reel-scrub .reel {
    height: calc(var(--chapter-count) * var(--chapter-span)
                 + var(--reel-lead)
                 + var(--chapter-overlap) + 100svh);
  }

  .reel-scrub .reel__stage {
    position: sticky;
    top: 0;
    height: 100svh;
    display: grid;
    place-items: center;
    overflow: clip;
  }

  .reel-scrub .chapter {
    min-height: 0;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    animation: chapter-life 1s linear both paused;
  }

  .reel-scrub .chapter--hero { animation-name: hero-life; }
  .reel-scrub .chapter--last { animation-name: chapter-arrive-hold; }
  .reel-scrub .chapter__title { animation: title-settle 1s linear both paused; }
  .reel-scrub .chapter__body  { animation: body-reveal 1s linear both paused; }
}

/* The four beats: arrive 0–7%, settle 7–30%, hold 30–88%, depart 88–96%.
   Hold is deliberately by far the longest — it is the only beat during which
   anyone can actually read.

   The departure completes at 96%, just before the successor starts at 96.2%,
   so the two are never on screen together. The small vertical drift — in from
   below, out upwards — gives the handoff a direction, so the eye follows one
   chapter out and the next one in rather than watching things dissolve in
   place.

   visibility and pointer-events are discrete: they flip at the *midpoint*
   between the keyframes declaring them, which is why `visible` is pinned at
   1% rather than at 7%. Declaring it at 7% would flip at 3.5%, popping the
   chapter into existence already at half opacity. */

@keyframes chapter-life {
  0%   { opacity: 0; visibility: hidden; pointer-events: none;
         transform: scale(0.94) translateY(2.5vh); }
  1%   { visibility: visible; }
  7%   { opacity: 1; transform: scale(1) translateY(0); }
  30%  { pointer-events: auto; }
  88%  { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; }
  90%  { pointer-events: none; }
  96%  { opacity: 0;
         transform: scale(var(--scale-depart)) translateY(-3vh); }
  99%  { visibility: visible; }
  100% { opacity: 0; visibility: hidden; pointer-events: none;
         transform: scale(var(--scale-depart)) translateY(-3vh); }
}

/* Same shape minus the arrival — the hero is fully present at scroll 0. */
/* The departure starts at 50% rather than 88% because the range under it is
   now 70svh rather than 200svh. Held to 88% of that, the wordmark would have
   under 3svh of scroll to get off the screen in and would read as a cut
   rather than as a lift.

   Two numbers set the opening and they are chosen together, so moving one
   alone will spoil it: this 50%, and --hero-span. Against HERO_REST = 0 in
   scripts/scroll.js — the page lands exactly where the browser put it, with
   no runway above it that nobody will ever scroll up into — the visitor gets
   the wordmark solid for the first ~35svh of scrolling, watches it lift over
   the next ~32, and has chapter 1's title about 18svh after that. Call it
   four fifths of a screen from landing to the first sentence, against a
   screen and a quarter before; and the part of that which was a motionless
   wordmark is now under half what it was.

   scripts/scroll.js knows this 0.50 as HERO_HOLD_HI, which is how THE ASSIST
   knows a visitor stopped over the hero is already settled and needs no
   help. */
@keyframes hero-life {
  0%   { opacity: 1; visibility: visible; transform: scale(1) translateY(0); }
  50%  { opacity: 1; transform: scale(1) translateY(0); }
  96%  { opacity: 0;
         transform: scale(var(--scale-depart)) translateY(-3vh); }
  99%  { visibility: visible; }
  100% { opacity: 0; visibility: hidden;
         transform: scale(var(--scale-depart)) translateY(-3vh); }
}

/* The final chapter arrives and stays. Departing would leave a full empty
   viewport between it and the closing footer, because the stage still has its
   own height to scroll away after the last range ends. Holding lets Members
   simply scroll up as the footer scrolls in. */
@keyframes chapter-arrive-hold {
  0%   { opacity: 0; visibility: hidden; pointer-events: none;
         transform: scale(0.94) translateY(2.5vh); }
  1%   { visibility: visible; }
  7%   { opacity: 1; transform: scale(1) translateY(0); }
  30%  { pointer-events: auto; }
  100% { opacity: 1; visibility: visible; pointer-events: auto;
         transform: scale(1) translateY(0); }
}

/* The title arrives oversized and slightly low, then settles into place.
   Both are transforms — letting the body copy push the title up through
   layout would thrash every frame and could not run on the compositor.
   The offset is in `em` so it stays proportional to the title's own size. */
@keyframes title-settle {
  0%   { transform: scale(var(--scale-arrive)) translateY(0.32em); }
  30%  { transform: scale(1) translateY(0); }
  100% { transform: scale(1) translateY(0); }
}

@keyframes body-reveal {
  0%   { opacity: 0; transform: translateY(1.5rem); }
  9%   { opacity: 0; transform: translateY(1.5rem); }
  30%  { opacity: 1; transform: translateY(0); }
  100% { opacity: 1; transform: translateY(0); }
}


/* 9. Chapter typography =================================================== */

.chapter__title {
  margin: 0;
  font-size: var(--step-title);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1.04;
  text-wrap: balance;
}

.chapter__body {
  display: grid;
  justify-items: center;
  gap: 1rem;
  width: 100%;
  max-width: min(var(--measure), 100%);
}

.chapter__body p { margin: 0; }

.chapter__lede {
  font-size: var(--step-lede);
  font-weight: 400;
  line-height: 1.35;
  letter-spacing: -0.015em;
  text-wrap: balance;
}

.chapter__body p:not(.chapter__lede) {
  font-size: var(--step-body);
  line-height: 1.6;
  color: color-mix(in srgb, var(--ink) 68%, transparent);
}

/* Inline spec list, dot-separated. */
.spec {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.4rem 1.4rem;
  margin: 0.25rem 0 0;
  padding: 0;
  list-style: none;
  font-size: var(--step-body);
  color: color-mix(in srgb, var(--ink) 68%, transparent);
}

.spec li { position: relative; }

.spec li + li::before {
  content: "·";
  position: absolute;
  left: -0.78rem;
  opacity: 0.45;
}

.roster {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.6rem 1.5rem;
  margin: 0.5rem 0 0;
  padding: 0;
  width: 100%;
  list-style: none;
  font-size: var(--step-body);
  color: color-mix(in srgb, var(--ink) 55%, transparent);
}

.cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 3rem clears the 44px minimum tap target with room to spare. */
  min-height: 3rem;
  margin-top: 0.5rem;
  padding-inline: 1.6rem;
  border: 1px solid color-mix(in srgb, var(--ink) 18%, transparent);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.5);
  color: var(--ink);
  font-size: var(--step-body);
  font-weight: 500;
  text-decoration: none;
  transition: background 0.35s ease, border-color 0.35s ease;
}

.cta:hover,
.cta:focus-visible {
  background: rgba(255, 255, 255, 0.85);
  border-color: color-mix(in srgb, var(--ink) 40%, transparent);
}


/* 10. Closing footer ======================================================= */

.closing {
  position: relative;
  z-index: 4;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 1.1rem;
  min-height: 100svh;
  padding-block: calc(2rem + env(safe-area-inset-top))
                 calc(2rem + env(safe-area-inset-bottom));
  padding-inline: calc(1.5rem + env(safe-area-inset-left))
                  calc(1.5rem + env(safe-area-inset-right));
  text-align: center;
}

.closing__mark {
  margin: 0;
  font-size: var(--step-title);
  font-weight: 500;
  letter-spacing: -0.03em;
  /* The delay is for coming BACK, and it is timed to LAND WITH the plate, not
     after it. Section 15's rectangle is dismissed at t=0 and finishes
     collapsing onto the underline at about t=710ms (150ms for the content to
     leave, then a 560ms morph); .touch-open comes off at t=150, so 160ms of
     delay plus a 400ms fade puts the wordmark back at exactly 710.

     It used to be 360ms of delay plus 450ms of fade, which finished at 960 —
     a quarter of a second after the rectangle had gone. On a desktop that is a
     beat. On a phone, where the card is the whole screen, it is a quarter of a
     second of blank page, and it reads as the site having lost its place. The
     rule below takes the delay off again for going away. */
  transition: opacity 0.4s ease 0.16s;
}

.closing__line { margin: 0; font-size: var(--step-body); }

.closing__line a {
  color: color-mix(in srgb, var(--ink) 68%, transparent);
}

.closing__line a:hover { color: var(--ink); }

.touch-origin {
  position: relative;
  display: inline-block;
  text-decoration: none;
  transition: color 0.35s ease, opacity 0.3s ease 0.26s;
}

/* This one pixel is the whole of section 15. scripts/touch.js measures it and
   grows the card out of it, so its rect, its colour and its position on the
   page are all load-bearing. */
.touch-origin__rule {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0.12em;
  height: 1px;
  background: currentColor;
  pointer-events: none;
}

html.touch-open {
  overflow: hidden;
}

html.touch-open .closing__mark {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease 0s;
}

/* Faster than the mark above it, and deliberately so: the plate leaves this
   rule's exact rect on the same frame, and any overlap at all reads as two
   lines rather than as one line becoming a rectangle. */
html.touch-open .touch-origin {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.12s linear 0s;
}

html.touch-open .index {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}


/* 11. Mobile & landscape ================================================== */
/* The choreography is retuned by overriding tokens, not by writing a second
   set of keyframes. */

@media (max-width: 48rem) {
  :root {
    /* Thirteen screens' worth of scroll is fine on a trackpad and tiring on a
       thumb. --chapter-overlap tracks this automatically, so the handoff
       timing is identical to desktop. */
    --chapter-span: 165svh;
    --step-title-band: clamp(1.4rem, 6.4vw, 2rem);
    /* The arrive beat scales UP, and scale is a transform — it overflows the
       chapter's padding rather than being constrained by it. Measured across
       phone widths, the danger band is ~380–400px, where "Why is a standard
       needed?" just fits on one line: at 1.15 that left only 11px of margin at
       384px. 1.08 restores ~22px there while still reading as an arrival. */
    --scale-arrive: 1.08;
    --scale-depart: 1.3;
    --measure: 28ch;
  }

  /* The band is the same idea at this width but proportionally taller, since
     a phone gives a paragraph three or four lines instead of two. The camera
     lift in scripts/cinema.js widens to match. */
  .film-live .chapter--band {
    --scale-arrive: 1.05;
    --scale-depart: 1.22;
  }
}

/* Landscape phones leave ~390px of height for a title plus its copy. */
@media (max-height: 30rem) and (orientation: landscape) {
  :root {
    --chapter-span: 150svh;
    --step-title: clamp(1.4rem, 5vh, 2.4rem);
    --step-title-band: clamp(1.15rem, 4.4vh, 1.7rem);
    --step-lede:  clamp(0.95rem, 2.8vh, 1.15rem);
    --step-body:  clamp(0.85rem, 2.2vh, 1rem);
  }

  .chapter__inner { gap: 0.7rem; }
  .chapter__body  { gap: 0.6rem; }

  /* There is no room for both a band and a subject here, so the band gets as
     little as it can live on and the scrim tightens onto it. */
  .film-live .chapter--band { padding-bottom: 1.1rem; }
  .film-live .chapter--band .chapter__inner { gap: 0.45rem; }
  .scrim { height: 62svh; }
}


/* 12. Performance tier ===================================================== */
/* There are no rules in this section any more, and that is the finding worth
   recording rather than a section worth deleting.

   scripts/perf.js still puts .gpu-low on <html>, and the tier still does real
   work — but all of it is now in JavaScript, through PPPerf.pixelRatio() and
   PPPerf.onTier() in scripts/cinema.js, which size the film's multisampled
   framebuffer. Nothing in this stylesheet reads the class.

   Both rules that used to live here were promoted to defaults, because both
   turned out to be the right answer for every machine rather than a
   concession for slow ones:

     - the glaze is held still on every tier (section 4). A blur whose input
       moves can never be cached, and it was moving on every page, forever.
     - the grain no longer blends (section 6). A full-viewport blend mode
       forces the whole stack beneath it to be flattened and read back every
       frame, which is the most expensive thing a page can ask a compositor
       for and is not worth a difference of six values out of 255.

   What is left of this page's full-screen pixel work is the film itself, and
   the film is the point of the page. If a third rule ever wants to live here,
   the question to ask first is whether it should be the default instead. */


/* 13. Input guard ========================================================= */
/* The half of scripts/guard.js that is CSS, and it is CSS for two reasons
   rather than one.

   It is the only half that reaches iOS. A long press there raises the callout
   — the copy/look-up/share sheet, and with it Share > View Source in any
   browser that offers one — and that is not a `contextmenu` event, is not a
   DOM event of any kind, and cannot be cancelled from script. This property
   is the only handle on it that exists.

   It is also the only half that survives JavaScript being switched off, which
   is the first thing anyone determined to read the page will do, and which
   takes the whole of guard.js with it.

   Section 13 buys deterrence at a real cost to ordinary visitors: nobody can
   select a sentence of the spec copy to quote it. Removing these three
   declarations and the selection listeners in scripts/guard.js — together,
   they are one decision — restores that and changes nothing else. */

body {
  -webkit-user-select: none;
  user-select: none;
  /* iOS long-press callout. Prefixed only: there is no standard property for
     this and Safari is the only engine that needs one. */
  -webkit-touch-callout: none;
}

/* Dragging an element out of the page is a second route to its source, and
   `user-select` does not cover it. */
img,
canvas,
svg,
a {
  /* Prefixed only, and there is no unprefixed form to pair it with: this has
     never been a standard property. WebKit and Blink honour it, Gecko covers
     the same ground through the dragstart listener in scripts/guard.js. */
  -webkit-user-drag: none;
}

/* The CTA is still a link and still has to look like it can be pressed —
   `user-select: none` does not affect that, but a stray text cursor over it
   would. */
.cta { cursor: pointer; }


/* 14. The station index =================================================== */
/* Thirteen chapters is about twenty-two screens of scrolling on a phone. The
   film needs that distance. The reader does not, and the gap between those
   two is what scroll fatigue actually is.

   Drawn as a dimension line rather than as a scrollbar, because that is the
   language the rest of the page is already speaking: a column of station
   ticks against the right margin, the current one extended and inked, exactly
   as a drawing marks the section it is calling out. It says three things at
   once — where you are, how much is left, and that you may leave — and the
   middle one is the important one. Not knowing how far a scroll runs is what
   makes it feel long, more than its length is.

   scripts/scroll.js hangs .index-live on <html> and moves aria-current. */

.index {
  position: fixed;
  z-index: 5;                /* above the reel; it has to be reachable */
  top: 50%;
  right: calc(1rem + env(safe-area-inset-right));
  transform: translateY(-50%);
  display: grid;
  gap: 0.45rem;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.55s ease, visibility 0.55s;
}

/* Absent over the hero — there is nothing behind it to go back to — and gone
   again once the roster hands the scroll to the footer. */
.index-live .index {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

.index__link {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  /* The rule is 2px tall. The target is not: 44px wide and 22px tall, so the
     whole row either side of the tick is pressable. */
  width: 44px;
  height: 22px;
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}

.index__link:focus-visible {
  outline: 1px solid color-mix(in srgb, var(--ink) 45%, transparent);
  outline-offset: 3px;
  border-radius: 2px;
}

.index__tick {
  display: block;
  width: 14px;
  height: 2px;
  background: color-mix(in srgb, var(--ink) 20%, transparent);
  /* Length carries the state, not colour alone: the mark still reads at a
     glance in a photograph, in greyscale, and to anyone who cannot separate
     20% ink from 100% ink by hue. */
  transition: width 0.4s cubic-bezier(0.2, 0.7, 0.2, 1),
              background-color 0.4s ease;
}

.index__link[aria-current="true"] .index__tick {
  width: 30px;
  background: var(--ink);
}

.index__label {
  position: absolute;
  right: 44px;
  white-space: nowrap;
  font-size: 0.72rem;
  letter-spacing: 0.01em;
  color: color-mix(in srgb, var(--ink) 62%, transparent);
  opacity: 0;
  transform: translateX(4px);
  transition: opacity 0.3s ease, transform 0.3s ease;
  pointer-events: none;
}

/* Hover only. On a touch screen there is no hover to reveal it with, and a
   label that appears on press appears too late to have chosen anything by. */
@media (hover: hover) {
  .index__link:hover .index__tick {
    width: 22px;
    background: color-mix(in srgb, var(--ink) 55%, transparent);
  }
  .index__link:hover .index__label,
  .index__link:focus-visible .index__label {
    opacity: 1;
    transform: translateX(0);
  }
}

/* Phones: the same index, turned along the top edge — the one band of a phone
   screen this page leaves empty. The copy sits at the bottom in its own band
   and the drawing is centred, so nothing here can collide with either, which
   the right margin at this width could not promise. */
@media (max-width: 48rem) {
  .index {
    top: calc(0.85rem + env(safe-area-inset-top));
    right: auto;
    left: 50%;
    transform: translateX(-50%);
    grid-auto-flow: column;
    gap: 0.34rem;
  }

  .index__link {
    width: 18px;
    height: 34px;
    justify-content: center;
  }

  /* Turned on its side the ticks all share a length, so weight carries the
     state instead. */
  .index__tick { width: 100%; }
  .index__link[aria-current="true"] .index__tick {
    width: 100%;
    height: 3px;
  }

  .index__label { display: none; }
}

/* Landscape phones keep the right margin — there is width to spare and almost
   no height — but the column has to close up to fit inside it. */
@media (max-height: 30rem) and (orientation: landscape) {
  .index { gap: 0.22rem; }
  .index__link { height: 16px; }
  .index__label { display: none; }
}

/* Without the pinned layout the reel is an ordinary stacked document, the
   headings are all reachable by scrolling, and scripts/scroll.js leaves every
   one of these as the plain fragment link it already is. Nothing has to hide:
   .index-live is simply never set, so the index stays out of the way. */


/* A motion-preferences section was removed on purpose: every visitor gets the
   full cinema and the paged reel, regardless of an OS-level reduced-motion
   setting. scripts/scroll.js and scripts/reel.js no longer check for it
   either. The glaze used to be the cheap half of that question — one selector
   would have stilled it — but it is stilled for everybody now, so what is left
   to honour is the film and the chapter transitions, which is the whole of the
   page and not a corner of it. */


/* 15. Get in touch ======================================================== */
/* The underline is the form.
 *
 * .touch-plate is a bare fixed box laid over .touch-origin__rule at exactly
 * its rect, and scripts/touch.js animates its GEOMETRY — left, top, width,
 * height, corner radius and fill together — to the sheet's. A 1px ink line
 * thickens into a ceramic card and keeps a true 1px edge and a true corner
 * the whole way there. Scaling the sheet itself would have been cheaper and
 * would have smeared both, which is the one thing this move is about.
 *
 * On landing the sheet takes its own chrome and the plate is hidden on the
 * same frame at the same rect. Two identical boxes, so the handover cannot be
 * seen, and every reflow after it belongs to the sheet alone.
 *
 * The card is a FIXED SIZE and asks one question at a time. That is what
 * makes the target rect knowable before the schema has arrived, and it is the
 * shape the reel is already in: one statement per frame, arriving from below,
 * departing upward, with a row of station ticks for how far along it is.
 */

.touch-layer {
  position: fixed;
  inset: 0;
  z-index: 6;
  display: grid;
  place-items: center;
  padding-block: calc(1.25rem + env(safe-area-inset-top))
                 calc(1.25rem + env(safe-area-inset-bottom));
  padding-inline: calc(1.5rem + env(safe-area-inset-left))
                  calc(1.5rem + env(safe-area-inset-right));
  pointer-events: none;
}

/* `display: grid` above out-specifies the user agent's `[hidden]`, so the
   attribute on its own does not close this. Nothing showed while the card had
   no chrome of its own and its body sat at opacity 0; the close mark is drawn
   in the corner and would have sat there over the footer. */
.touch-layer[hidden],
.touch-plate[hidden] { display: none; }

.touch-layer.is-open { pointer-events: auto; }

/* `fixed`, not `absolute`. An absolutely positioned child of this layer
   resolves `inset: 0` against its PADDING box, which would leave the veil
   inset by the safe-area gutters above and a rim of unwashed page around it. */
.touch-veil {
  position: fixed;
  inset: 0;
  /* Almost nothing, and that is the finding. Every wash strong enough to read
     as a scrim took the colour out of the glaze with it, and the glaze is the
     best thing on this page. What separates the card from the page is the
     card: its own fill, its own edge, its own shadow, and a drawing pushed in
     behind it. All this adds is a whisper at the outer edge, so the frame has
     somewhere to end. */
  background: radial-gradient(120% 90% at 50% 52%,
      transparent 0%,
      transparent 60%,
      color-mix(in srgb, var(--porcelain) 24%, transparent) 100%);
  opacity: 0;
  transition: opacity 0.85s ease;
}

.touch-layer.is-open .touch-veil { opacity: 1; }

/* THE PAGE BEHIND STOPS MOVING, and there is nothing here that stops it.
   
   This block used to pause the glaze's drift for as long as the card was up.
   It does not need to any more: section 4 holds the glaze still on every page
   at all times, so by the time the card opens the background has already been
   a still picture for the whole visit.
   
   What else used to be here and is gone: a bloom the size of the viewport
   fading and scaling in, a `saturate()` transition on the glaze (which
   re-blurs the whole layer for the length of the transition), and a held WebGL
   frame with the canvas transformed behind the card. All of them were paid for
   in continuous GPU time to decorate a page that is being covered up. The open
   card now starts nothing at all. */

/* The growing rectangle ---------------------------------------------------- */
.touch-plate {
  position: fixed;
  z-index: 1;
  border: 1px solid color-mix(in srgb, var(--ink) 16%, transparent);
  background: var(--card-fill);
  box-shadow: 0 1.6rem 3.4rem -1.6rem color-mix(in srgb, var(--ink) 24%, transparent);
  pointer-events: none;
}

/* The card ----------------------------------------------------------------- */
.touch-sheet {
  position: relative;
  z-index: 2;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  width: min(var(--card-w), 100%);
  height: min(var(--card-h), 100%);
  padding: var(--card-pad);
  border: 1px solid transparent;
  border-radius: var(--card-radius);
  outline: none;
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}

/* Chrome arrives only once the plate has finished growing, and is identical
   to the plate's, so the swap between them is not a crossfade — it is the
   same rectangle, drawn by a different element. */
.touch-sheet.is-landed,
.touch-sheet--static {
  border-color: color-mix(in srgb, var(--ink) 16%, transparent);
  background: var(--card-fill);
  box-shadow: 0 1.6rem 3.4rem -1.6rem color-mix(in srgb, var(--ink) 24%, transparent);
}

/* No .gpu-low branch here any more, and that is the point: with no filter on
   either box there is nothing for the low tier to opt out of. One card, one
   fill, every machine. */

/* Asymmetric on purpose. Landing is instant, because the content has its own
   arrival beats to play (the title's body-reveal, the question's step-in) and
   a fade under them would be a second, slower fade over the top of the first.
   Leaving is a real 0.18s fade, because the close used to be a hard cut: the
   body went to opacity 0 and the card's chrome vanished with it on the same
   frame, leaving 150ms of empty page before the plate appeared to collapse.
   That gap was the glitch on the way out. */
.touch-sheet__body {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: clamp(0.8rem, 2.2vw, 1.2rem);
  min-height: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease;
}

.touch-sheet.is-landed .touch-sheet__body,
.touch-sheet--static .touch-sheet__body {
  opacity: 1;
  pointer-events: auto;
  transition: opacity 0s;
}

/* Not the statement on screen — the question under it is. This is the line
   that says what the next minute is going to be, and then gets out of the
   way. */
.touch-sheet__title {
  margin: 0;
  padding-inline: 1.6rem;   /* clears the close mark in the corner */
  font-size: var(--step-body);
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1.35;
  text-align: center;
  text-wrap: balance;
  color: color-mix(in srgb, var(--ink) 56%, transparent);
}

.touch-sheet.is-landed .touch-sheet__title,
.touch-sheet--static .touch-sheet__title {
  animation: body-reveal 1.05s linear both;
}

/* Held with the body rather than merely near it. This is a sibling of
   .touch-sheet__body, not a child, so the opacity rule above does not reach
   it — and a close mark floating in mid air over a rectangle that has not
   finished growing is the one thing in this transition that gives it away. */
.touch-close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  display: grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, background 0.3s ease;
  -webkit-tap-highlight-color: transparent;
}

.touch-sheet.is-landed .touch-close,
.touch-sheet--static .touch-close {
  opacity: 0.4;
  pointer-events: auto;
  transition: opacity 0s, background 0.3s ease;
}

.touch-sheet.is-landed .touch-close:hover,
.touch-sheet.is-landed .touch-close:focus-visible,
.touch-sheet--static .touch-close:hover,
.touch-sheet--static .touch-close:focus-visible {
  opacity: 1;
  background: rgba(255, 255, 255, 0.5);
}

/* Two 1px strokes. Everything else on this page that means something is drawn
   the same way. */
.touch-close::before,
.touch-close::after {
  content: "";
  position: absolute;
  width: 0.8rem;
  height: 1px;
  background: currentColor;
}

.touch-close::before { transform: rotate(45deg); }
.touch-close::after  { transform: rotate(-45deg); }

/* contact.html ------------------------------------------------------------- */
/* The no-JS fallback: the same card and the same form, on a plain white page.
   No glaze and no grain, because both of those are the film page's material
   and this page is not the film page — it is the one surface on this site
   whose only job is to be a form.

   The card is already an opaque panel everywhere (see --card-fill), so all
   this page changes is its colour: --card-fill is a warm porcelain, and on
   flat white a warm panel reads as dirty rather than as material. White on
   white, with the edge and the shadow doing the work. The two translucent
   fills inside it — the choices and the CTA — need a real one here for the
   same reason. */

.page--plain {
  background-color: #fff;
}

.page--plain .touch-sheet--static {
  border-color: color-mix(in srgb, var(--ink) 14%, transparent);
  background: #fff;
  box-shadow: 0 1.4rem 3rem -1.8rem color-mix(in srgb, var(--ink) 22%, transparent);
}

.page--plain .touch__choice { background: color-mix(in srgb, var(--ink) 3%, transparent); }
.page--plain .touch__choice:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
.page--plain .touch__choice:has(.touch__radio:checked) { background: color-mix(in srgb, var(--ink) 7%, transparent); }

.page--plain .cta { background: transparent; }
.page--plain .cta:hover,
.page--plain .cta:focus-visible { background: color-mix(in srgb, var(--ink) 4%, transparent); }

.touch {
  position: relative;
  z-index: 4;
  display: grid;
  /* A definite height, and rows rather than centred content, so the card's own
     `height: min(--card-h, 100%)` has something for that 100% to resolve
     against. Left to an auto row it resolves to nothing, the min() falls back
     to auto, and the fallback page gets a card the height of one question
     instead of the one this form is laid out for. */
  grid-template-rows: minmax(0, 1fr) auto;
  justify-items: center;
  align-items: center;
  gap: 1.2rem;
  height: 100svh;
  min-height: 100svh;
  padding-block: calc(2rem + env(safe-area-inset-top))
                 calc(2rem + env(safe-area-inset-bottom));
  padding-inline: calc(1.5rem + env(safe-area-inset-left))
                  calc(1.5rem + env(safe-area-inset-right));
}

.touch-sheet--static { max-height: 100%; }

.touch__status {
  margin: 0;
  font-size: var(--step-body);
  text-align: center;
  color: color-mix(in srgb, var(--ink) 68%, transparent);
}

.touch__status a {
  color: color-mix(in srgb, var(--ink) 68%, transparent);
  text-underline-offset: 0.3em;
}

.touch__status a:hover { color: var(--ink); }

/* Waiting -----------------------------------------------------------------
   scripts/touch.js paints this the moment the card opens, and the form
   replaces it when the schema lands. Three ticks in the index's own language,
   breathing rather than spinning: nothing on this page spins. */
.touch__wait {
  display: flex;
  align-self: center;
  justify-content: center;
  gap: 0.34rem;
}

.touch__wait span {
  width: 14px;
  height: 2px;
  background: color-mix(in srgb, var(--ink) 30%, transparent);
  animation: wait-breathe 1.6s ease-in-out infinite;
}

.touch__wait span:nth-child(2) { animation-delay: 0.18s; }
.touch__wait span:nth-child(3) { animation-delay: 0.36s; }

@keyframes wait-breathe {
  0%, 100% { opacity: 0.25; }
  50%      { opacity: 1; }
}

/* The form ----------------------------------------------------------------- */

.touch__mount {
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  min-height: 0;
}

/* Row order is the reading order: how far along, then the question, then the
   way forward. The ticks used to sit under the actions, at the foot of the
   card, which is the last place anyone looks and the wrong place to answer
   "how much of this is left" — that is a question asked before starting, not
   after finishing. */
.touch__form {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  gap: clamp(0.7rem, 1.8vw, 1.1rem);
  min-height: 0;
  text-align: center;
}

/* One question, centred in what is left of the card after the header, the
   actions and the ticks have taken theirs. `safe` keeps a question that
   overflows on a short screen from being centred out through the top edge,
   where nothing could scroll it back. */
.touch__step {
  display: grid;
  justify-items: center;
  align-content: center;
  align-content: safe center;
  gap: clamp(0.9rem, 2.2vw, 1.4rem);
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* Arrive from below, depart upward — the reel's own direction of travel, at a
   card's scale rather than a screen's. Held until the plate has landed: on
   the first question the schema often beats the morph, and a question that
   animated in behind a rectangle still growing would be over before the card
   it belongs to exists. The selector simply does not match until then, and a
   CSS animation starts when its selector starts matching. */
.touch-sheet.is-landed .touch__step.is-in,
.touch-sheet--static .touch__step.is-in {
  animation: step-in 0.62s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}

.touch-sheet.is-landed .touch__step.is-out,
.touch-sheet--static .touch__step.is-out {
  animation: step-out 0.24s ease-in both;
}

.touch-sheet.is-landed .touch__step.is-in[data-dir="back"],
.touch-sheet--static .touch__step.is-in[data-dir="back"] {
  animation-name: step-in-back;
}

.touch-sheet.is-landed .touch__step.is-out[data-dir="back"],
.touch-sheet--static .touch__step.is-out[data-dir="back"] {
  animation-name: step-out-back;
}

@keyframes step-in {
  0%   { opacity: 0; transform: translateY(1.4rem) scale(0.985); }
  100% { opacity: 1; transform: none; }
}

@keyframes step-out {
  0%   { opacity: 1; transform: none; }
  100% { opacity: 0; transform: translateY(-1.1rem) scale(1.04); }
}

@keyframes step-in-back {
  0%   { opacity: 0; transform: translateY(-1.1rem) scale(1.04); }
  100% { opacity: 1; transform: none; }
}

@keyframes step-out-back {
  0%   { opacity: 1; transform: none; }
  100% { opacity: 0; transform: translateY(1.4rem) scale(0.985); }
}

.touch__ask {
  margin: 0;
  font-size: var(--step-ask);
  font-weight: 500;
  letter-spacing: -0.025em;
  line-height: 1.16;
  text-wrap: balance;
}

.touch__control {
  width: min(22rem, 100%);
  min-height: 2.9rem;
  padding: 0.35rem 0.2rem 0.5rem;
  border: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--ink) 24%, transparent);
  border-radius: 0;
  background: transparent;
  color: var(--ink);
  caret-color: var(--ink);
  font-family: inherit;
  font-size: max(1rem, var(--step-lede));
  font-weight: 400;
  line-height: 1.4;
  text-align: center;
  outline: none;
  transition: border-color 0.35s ease;
  -webkit-user-select: text;
  user-select: text;
}

.touch__control:hover { border-bottom-color: color-mix(in srgb, var(--ink) 42%, transparent); }
.touch__control:focus { border-bottom-color: color-mix(in srgb, var(--ink) 75%, transparent); }
.touch__control[aria-invalid="true"] { border-bottom-color: color-mix(in srgb, var(--ink) 55%, transparent); }

/* Choices ------------------------------------------------------------------
   A select on a page like this is a system widget dropped into a drawing.
   Every option is on screen instead, as a real radio group: arrow keys,
   Space and the label association all come free, and pointing at one is a
   single tap rather than a tap, a scroll and a second tap. */
.touch__choices {
  display: grid;
  gap: 0.36rem;
  width: min(26rem, 100%);
}

.touch__choice {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  min-height: 2.6rem;
  padding: 0.45rem 0.9rem;
  border: 1px solid color-mix(in srgb, var(--ink) 11%, transparent);
  border-radius: 0.75rem;
  background: rgba(255, 255, 255, 0.32);
  font-size: var(--step-body);
  line-height: 1.25;
  text-align: start;
  cursor: pointer;
  transition: background 0.28s ease, border-color 0.28s ease;
  -webkit-tap-highlight-color: transparent;
}

.touch__choice:hover {
  background: rgba(255, 255, 255, 0.6);
  border-color: color-mix(in srgb, var(--ink) 24%, transparent);
}

.touch__choice:has(.touch__radio:checked) {
  background: rgba(255, 255, 255, 0.78);
  border-color: color-mix(in srgb, var(--ink) 40%, transparent);
}

.touch__choice:has(.touch__radio:focus-visible) {
  outline: 1px solid color-mix(in srgb, var(--ink) 45%, transparent);
  outline-offset: 3px;
}

/* Not display:none, and not visibility:hidden: either would take the radio
   out of the tab order and off the arrow keys, which are the entire reason
   for using real radios here. */
.touch__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.touch__choice-mark {
  flex: none;
  width: 0.85rem;
  height: 0.85rem;
  border: 1px solid color-mix(in srgb, var(--ink) 30%, transparent);
  border-radius: 50%;
  transition: border-color 0.28s ease, background 0.28s ease;
}

.touch__radio:checked + .touch__choice-mark {
  border-color: var(--ink);
  background: radial-gradient(circle, var(--ink) 0 42%, transparent 44%);
}

.touch__choice-label { flex: 1 1 auto; }

/* Staggered behind the question, exactly as the reel staggers a chapter's
   body behind its title. */
.touch-sheet.is-landed .touch__step.is-in .touch__choice,
.touch-sheet--static .touch__step.is-in .touch__choice {
  animation: body-reveal 0.85s linear both;
  animation-delay: calc(0.12s + var(--i) * 55ms);
}

.touch__error,
.touch__form-error {
  margin: 0;
  min-height: 1.1em;
  font-size: 0.85em;
  line-height: 1.4;
  color: color-mix(in srgb, var(--ink) 68%, transparent);
}

.touch__actions {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 0.5rem;
}

.touch__back {
  grid-column: 1;
  justify-self: start;
  padding: 0.5rem 0.1rem;
  border: 0;
  background: transparent;
  color: color-mix(in srgb, var(--ink) 55%, transparent);
  font-family: inherit;
  font-size: var(--step-body);
  cursor: pointer;
  transition: color 0.3s ease;
  -webkit-tap-highlight-color: transparent;
}

.touch__back:hover,
.touch__back:focus-visible { color: var(--ink); }

.touch__submit {
  grid-column: 2;
  margin-top: 0;
}

.touch__form-error {
  grid-column: 1 / -1;
  grid-row: 2;
}

/* The station index again, on its side and one tick per question, sitting at
   the head of the card under the title. Length carries the state here for the
   same reason it does there: the mark still reads in greyscale, and the row
   still says how much is left. */
.touch__ticks {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.34rem;
  min-height: 2px;
}

.touch__tick {
  display: block;
  width: 14px;
  height: 2px;
  background: color-mix(in srgb, var(--ink) 16%, transparent);
  transition: width 0.4s cubic-bezier(0.2, 0.7, 0.2, 1),
              background-color 0.4s ease;
}

.touch__tick.is-done { background: color-mix(in srgb, var(--ink) 40%, transparent); }

.touch__tick.is-current {
  width: 30px;
  background: var(--ink);
}

/* Announced, never drawn: the ticks are the visible half of this and are
   hidden from assistive tech, since a row of thirty-pixel rules is not a
   sentence. */
.touch__live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Sent --------------------------------------------------------------------- */

.touch__sent {
  margin: 0;
  font-size: var(--step-title-band);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1.12;
}

.touch__sent-line {
  margin: 0;
  max-width: 26ch;
  font-size: var(--step-body);
  line-height: 1.55;
  color: color-mix(in srgb, var(--ink) 68%, transparent);
}

button.cta {
  font-family: inherit;
  appearance: none;
  -webkit-appearance: none;
}

.cta:disabled {
  opacity: 0.55;
  cursor: wait;
}

/* Phones: the card is as wide as the gutters allow and as tall as it needs,
   and the question steps down a size to keep the longest of them off five
   lines. */
@media (max-width: 48rem) {
  :root {
    --step-ask: clamp(1.1rem, 4.6vw, 1.4rem);
  }

  .touch-sheet__title { font-size: 0.9rem; }
}

/* Landscape phones: about 480px of glass, most of it wide. The card takes all
   the height there is and every band inside it closes up. */
@media (max-height: 30rem) and (orientation: landscape) {
  :root {
    --card-h: 100%;
    --card-pad: 1rem 1.4rem;
    --step-ask: clamp(1.05rem, 3.6vh, 1.35rem);
  }

  .touch-sheet__body { gap: 0.6rem; }
  .touch__form { gap: 0.55rem; }
  .touch__step { gap: 0.7rem; }
  .touch__choice { min-height: 2.4rem; padding-block: 0.35rem; }
  .touch__control { min-height: 2.4rem; }
}
