/* home.css — the six stations.
   Governed by docs/BUILD-CONTRACT.md. Hairlines do the structural work that
   boxes usually do: no cards, no radii, no shadows, no fills. */

/* ======================================================================
   THE PLATES — baked, graded photographs. Not shaders.
   We bake because Lusion are right that you don't need to do everything
   real-time, and because a mesh pipeline is outside this project's budget.
   (An earlier comment here claimed IZANAMI's atmosphere is "two CSS layers
   with no GSAP at all". It isn't — it's two WebGL2 canvases. See LEDGER D13.
   The decision survives the correction; the justification changed.)

   ONE COMPONENT, USED THREE TIMES. The hero, the almanac header and the
   threshold all run the identical mechanism: a sticky plate that lags the
   page at 0.19x while a veil of ground pours in from the top. Charles's
   note is the spec — "the consistent simple transitions are important to
   recreate the effect" — so there is deliberately no second code path.

   Each station drives its own progress variable (--p) from its own scrubbed
   ScrollTrigger. Same ratio, same wipe, same curve, three times.
   See docs/PLATE-RHYTHM.md.
   ====================================================================== */

/* THE STAGE. izanami's hero station is 1920px against an 855px viewport —
   2.24 viewports: one screen of content, then a WHOLE FURTHER SCREEN of
   nothing but plate departing. Ours was 100svh, which is why the exit read
   as abrupt: there was nowhere for it to happen.

   The stage is the unit, NOT the station. For the hero and the threshold the
   section IS the stage. For the almanac the stage wraps only the header, so
   the plate releases after one screen and the eleven entries run on dark
   ground below it — which is izanami's own construction
   (.homeProjects_contents over the plate, then .homeProjects_target over
   dark). Making the whole almanac a plate station pinned the photo behind all
   eleven rows and deleted the dark half of Charles's alternation. */
.plate-stage {
  /* --runway IS the pinned span: the plate is sticky and one screen tall, so
     it stays pinned for exactly (station height - 100svh). Everything below
     derives from this one number, which is why the ratio survives a viewport
     override. Setting min-height and the travel independently is how 2228x510
     silently drifted to 0.155 while every other window read 0.189. */
  --runway: 90svh;
  position: relative;
  min-height: calc(100svh + var(--runway));
}

/* The plate pins for (station - 100svh) = ~90svh, and travels 0.19 of that
   ≈ 17svh. The 4:3 asset carries ~20% vertical reserve at a 1.6:1 window, so
   that travel costs NO base scale — the old 1.22 zoom is gone. */
.plate {
  /* FULL BLEED. The plate lives inside a station, and the station is padded —
     at >=1900px it clamps content to 1800px, which at a 2529px window is 364px
     of padding a side. Inheriting that put dark bars down both edges of the
     hero and the plate stopped being a plate. Cancel the padding exactly,
     using the same token the station sets, so the two can never drift.
     (100vw is banned by checker rule L1 — it includes the scrollbar.) */
  margin-inline: calc(var(--station-pad, 0px) * -1);
  /* EXPLICIT width, not `auto`. As a grid/flex item `auto` resolves to
     fit-content, and the threshold station lays its children out that way, so
     the plate there collapsed to width:0 while the hero (a block context) was
     fine. Same rule, two layout modes, two different results. Stating the
     width removes the dependency entirely. */
  width: calc(100% + (var(--station-pad, 0px) * 2));
  position: sticky;
  top: 0;
  z-index: 0;
  height: 100svh;
  overflow: hidden;
  background: var(--ground);
}

.plate img {
  width: 100%;
  /* THE RESERVE IS ELEMENT HEIGHT, NOT `cover` OVERFLOW.
     This was wrong at first and the probe caught it: `object-fit: cover` gives
     the IMAGE room inside the element, but translating the element moves the
     whole box, so that room never comes into play — the plate lifted its own
     bottom edge and exposed 62px of bare ground at the end of the hero.
     Make the element itself 20svh taller than the pinned box instead. The
     overflow hangs below the fold, clipped by .plate, and the 17svh of travel
     eats into it with ~3svh to spare.
     Happy side effect at 1440x900: a 1440x1080 box IS 4:3, so the plate lands
     at its native aspect with no crop at all. */
  height: calc(100% + (var(--runway) * .22));
  object-fit: cover;
  /* Both anchors — moon at ~1/3, waterline at ~2/3 — sit inside the band that
     survives every window shape, including 2228x510. That is composed into
     the asset (docs/PLATE-RHYTHM.md), not corrected here. */
  object-position: 50% 50%;

  /* THE SINGLE BACKGROUND RATIO — 0.19x against a 1.0x page.
     Measured 0.191x on izanami and 0.189x on locomotive.ca, independently
     (LEDGER D14, contract §5). One ratio, every plate. Not three speeds —
     "nobody runs five parallax speeds".
     --p runs 0..1 across the pinned span (--runway), so the travel is
     literally 0.19 OF that span and the ratio reads off the computed matrix. */
  transform-origin: 50% 0;
  transform: translate3d(0, calc(var(--p, 0) * var(--runway) * -.19), 0)
             scale(calc(1 + (var(--p, 0) * .04)));

  /* The plate must NOT dim itself out. It used to run 1 -> 0.18, which fades
     the sky and the silhouette at the same rate and reads as the picture
     being turned down rather than the page arriving over it. The veil does
     the removing instead, and it kills the sky first. */
  opacity: calc(1 - (var(--p, 0) * .30));
  /* NO permanent `will-change`. It was here to promise the compositor a
     transform, but there are three of these now and each is a full-viewport
     image, so it held three large layers alive for the whole session whether
     they were moving or not. Chrome promotes an element while its transform or
     opacity is actually animating anyway; a standing hint just costs memory
     and can push the compositor into tiling. Removed and measured, not
     assumed. */
}

/* THE WIPE. Ground pours in from the top of the frame and drowns the sky, so
   the waterline is the last thing alive. izanami's exit is a wipe, not a fade
   — measured: at scroll 1400 its sky is entirely gone and the treeline is
   still legible (LEDGER D14). Static gradient, opacity only, no third loop. */
.plate__veil {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    180deg,
    var(--ground) 10%,
    color-mix(in srgb, var(--ground) 58%, transparent) 46%,
    transparent 76%);
  opacity: var(--p, 0);
  pointer-events: none;
}

/* ======================================================================
   THE DRIFT — izanami's cloud marquee, rebuilt from its own rules.

   Measured off the live site: opacity 1, mix-blend-mode normal, filter none
   on every element in the chain. Their translucency is NOT CSS — it lives in
   the image's alpha channel, a 7480px-wide strip on transparency. Ours is
   generated tileable by _qa/make-fog.py and proven seamless numerically
   (wrap delta 0.06 against an interior delta of 0.04).

   Their keyframes, verbatim:
     cloudMove01  0% translate(100%)  ->  100% translate(-100%)
     cloudMove02  0% translate(0)     ->  100% translate(-200%)
   Two tiles adjacent in a flex row, both sliding left 200% of their own
   width over 100s linear, the first delayed -50s so one tile is always
   covering while the other wraps. Their tile is 2.34x the viewport; ours is
   2.4x. Their drift works out at ~90px/s. Slow enough to never read as a
   loop.

   AMBIENT LOOP BUDGET: this uses ONE `infinite` declaration for all four
   tiles, where the two radial-gradient drifts it replaces used two. Checker
   rule M4 caps the site at 2, so this comes in under, not at, the ceiling.
   ====================================================================== */
.fog {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  /* The cloud group travels vertically with scroll too — izanami's
     .homeHeader_cloud carries a scroll-driven translateY (measured 198px).
     Same 0.19 the plate uses, deliberately NOT a third rate: contract §5 says
     pick one model and do not mix, and "nobody runs five parallax speeds". */
  transform: translate3d(0, calc(var(--p, 0) * var(--runway) * -.19), 0);
}

.fog__lane {
  position: absolute;
  left: 0;
  display: flex;
  width: 100%;
}
/* Two lanes at different heights and speeds. Depth comes from the offset and
   the rate, not from a second opacity — the alpha is already in the asset. */
.fog__lane--near { top: 14%; height: 64%; }
.fog__lane--far  { top: -8%; height: 82%; }

.fog__item {
  flex: 0 0 auto;
  /* 2.4x the layer width, matching izanami's 4488px tile against a 1920
     viewport. Wider than the screen so a whole tile is never in view. */
  width: 240%;
  height: 100%;
  background-repeat: no-repeat;
  background-size: 100% 100%;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
.fog__lane--near .fog__item { background-image: url("../img/fog-drift-a.webp"); }
.fog__lane--far  .fog__item { background-image: url("../img/fog-drift-b.webp"); }

/* The -50s delay is the whole trick: it puts the pair exactly half a cycle
   apart so the seam is always off-screen. */
/* 140s, not izanami's 100s. Charles asked for "a tiny bit slower" and the
   delays are HALF-CYCLE offsets, so they move with the duration or the seam
   stops being hidden: -70s is half of 140. */
.fog__item--a { animation-name: fogDriftA; animation-duration: 140s; animation-delay: -70s; }
.fog__item--b { animation-name: fogDriftB; animation-duration: 140s; }
/* The two lanes must NOT share a seam column. Measured: both lanes sat at
   exactly [-248..3208], so their wrap seams landed on the same pixel and
   stacked into one visible boundary. Offsetting the far lane by a third of a
   cycle keeps the two seams permanently apart. */
.fog__lane--far .fog__item--a { animation-delay: -117s; }
.fog__lane--far .fog__item--b { animation-delay: -47s; }
/* BOTH lanes run 100s, as izanami does. Depth comes from the offset, the
   height and the two different textures — not from a second speed. Relative
   rate check: izanami 4488px tile / 1920 viewport / 100s = 0.047 viewport
   widths per second; ours 3456 / 1440 / 140s = 0.034 — deliberately slower. */

@keyframes fogDriftA {
  from { transform: translate3d(100%, 0, 0); }
  to   { transform: translate3d(-100%, 0, 0); }
}
@keyframes fogDriftB {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-200%, 0, 0); }
}

/* Off-screen loops are wasted work (Rauno; and izanami pauses theirs with
   `animation-play-state: paused` until `.-inView`). Ours defaults to RUNNING
   and is paused by JS when out of view, so if the script never runs the fog
   still drifts instead of freezing. */
.fog.is-still .fog__item { animation-play-state: paused; }

/* Reduced motion: the fog holds still. It is atmosphere, not information, so
   a frozen frame loses nothing. */
@media (prefers-reduced-motion: reduce) {
  .fog__item { animation: none; }
}

/* The content of a plate station rides ON the plate, in the first screen.
   The plate is sticky and therefore already in flow, so the content is pulled
   back over it rather than pushed below it. */
.plate-stage > .station__over {
  position: relative;
  z-index: 1;
  margin-top: -100svh;
  min-height: 100svh;
  display: grid;
  align-content: center;
}

/* Below the fog the page closes to true ground so text has a real surface.
   This is also the second half of the hero exit: the plate is `fixed`, so this
   rises over it at a strict 1.0x while the plate lags at 0.19x — the body
   literally arrives on top of the picture, which is what "fades out into the
   body" means mechanically.

   It used to open at 88% opaque, which is not a dissolve, it is a horizontal
   rule: at the hero boundary you could see the exact pixel row where the page
   started. Opening at 45% and reaching full ground by 32% gives the edge
   somewhere to happen. It stays opaque enough that the plate — still at 0.70
   behind every later station — cannot ghost through a lower boundary. */
.station:not(.station--plate)::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--ground) 45%, transparent),
    color-mix(in srgb, var(--ground) 88%, transparent) 14%,
    var(--ground) 32%);
}

/* ======================================================================
   1 · HERO — four elements. The atmosphere carries the frame.
   ====================================================================== */

.station--hero {
  /* min-height comes from .station--plate (190svh) — the extra screen IS the
     departure runway. Centring now belongs to .station__over, which is the
     one-screen overlay riding on the plate, not to the whole station. */
  padding-block: 0;
}

/* Small. IZANAMI's hero H1 is 18px, Lusion's 36px. Neither hero uses display
   type — that is held back for section heads and the threshold. */
.hero__mark {
  margin: 0 0 var(--s-2);
  font-family: var(--display);
  font-variation-settings: "opsz" 18, "wght" 420, "SOFT" 14, "WONK" 0;
  font-size: 1.25rem;
  font-weight: 400;
  line-height: 1;
  letter-spacing: .3em;
  text-indent: .3em;
  color: var(--chalk);
}


.hero__intro { margin-bottom: var(--s-8); }
.hero__intro .util { margin-bottom: var(--s-3); }

.hero__line {
  font-family: var(--display);
  font-variation-settings: "opsz" 30, "wght" 360, "SOFT" 24, "WONK" 1;
  font-size: 1.75rem;
  line-height: 1.15;
  letter-spacing: var(--tr-mid);
  color: var(--chalk);
  max-width: 16ch;
}

/* utility footer: tide data on a hairline, bottom-right */
.tonight {
  position: absolute;
  right: var(--margin);
  bottom: calc(var(--s-9) + var(--s-8));
  width: var(--measure);
  margin: 0;
  border-top: var(--rule) solid color-mix(in srgb, var(--chalk) 42%, transparent);
  font-family: var(--mono);
  font-size: .7875rem;
  line-height: var(--lh-utility);
  letter-spacing: var(--tr-utility);
}
.tonight > div:not(.tonight__head) {
  display: grid;
  grid-template-columns: 8rem 1fr;
  padding: var(--s-3) 0;
  border-bottom: var(--rule) solid color-mix(in srgb, var(--chalk) 18%, transparent);
}
.tonight__head {
  display: block;
  padding: var(--s-3) 0;
  border-bottom: var(--rule) solid color-mix(in srgb, var(--chalk) 18%, transparent);
  white-space: nowrap;
  color: var(--sodium);
  text-transform: uppercase;
}
.tonight dt { color: var(--pewter); }
.tonight dd { margin: 0; text-align: right; color: var(--chalk); }

.cue {
  position: absolute;
  right: var(--margin);
  /* stepped up: the Concept mark now owns the bottom-right corner */
  bottom: calc(var(--s-8) + var(--s-6));
  display: flex;
  width: var(--measure);
  align-items: center;
  gap: var(--s-3);
}
.cue__rule {
  height: var(--rule);
  flex: 1;
  background: color-mix(in srgb, var(--chalk) 34%, transparent);
}

@media (max-width: 899px) {
  /* mobile is composed, not squashed: the data block leaves the corner and
     becomes a ledger under the line; the cue loses its rule. */

  .tonight { position: static; width: auto; margin-top: var(--s-7); }
  .tonight > div { grid-template-columns: 6.5rem 1fr; }
  .cue { position: static; width: auto; margin-top: var(--s-6); margin-bottom: var(--s-9); }
  /* NOT display:none — that deletes the word space and the first phone screen
     reads "the tide comes inscroll". Same bug as the old .slot. Collapse it. */
  .cue__rule { flex: 0 0 0; width: 0; margin-right: .3em; background: transparent; /* mark */ }
  .hero__line { font-size: 1.5rem; max-width: 12ch; }
  /* --pewter crossed the bright fog and vanished; the hero util gets a ground */
  .hero__intro .util,
  .cue {
    color: var(--chalk);
    text-shadow: 0 1px 18px var(--ground), 0 0 6px var(--ground);
  }
}

/* ======================================================================
   2 · PRACTICE
   ====================================================================== */

.kicker { color: var(--sodium); margin-bottom: var(--s-6); }

/* The reference layout: text block left, arch right, credits strip along the
   bottom of both. The station is a two-track grid and the notes span it. */
@media (min-width: 900px) {
  .station--practice {
    display: grid;
    /* The arch column scales with the grid too. Fixed at 430px it was a
       postage stamp on a 2309px screen while the track beside it ran on empty
       — the same freeze as --measure, one element down. 1.45 x the column
       keeps it a hair under two grid columns at every step. */
    grid-template-columns: minmax(0, 1fr) clamp(280px, 32%, calc(var(--measure) * 1.45));
    grid-template-rows: auto auto 1fr auto;
    column-gap: var(--s-8);
    align-items: start;
  }
  .station--practice > .kicker        { grid-column: 1; grid-row: 1; }
  .station--practice > .display       { grid-column: 1; grid-row: 2; }
  .station--practice > .practice__copy{ grid-column: 1; grid-row: 3; }
  /* The arch spans the whole text column's height and sits beside it. */
  .station--practice > .practice__arch{ grid-column: 2; grid-row: 1 / 4; }
  .station--practice > .notes         { grid-column: 1 / -1; grid-row: 4; }
}

.station--practice .display {
  font-size: clamp(2.4rem, 1.2rem + 3.4vw, 4rem);
  margin-bottom: var(--s-8);
  /* The target sets this heading warm, and it is the only station where the
     display type is not --chalk. Sampled off the reference at RGB(209,133,55),
     which is --ember (196,131,34) — a real palette token, so this is adopting
     the reference's STRUCTURE with our colour, not pulling its hex. */
  color: var(--ember);
}
.station--practice .display span { display: block; }
.station--practice .display span:last-child { padding-left: 2.4em; }

.practice__copy {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(var(--measure), 1fr));
  gap: var(--s-7);
  max-width: calc(var(--measure) * 2 + var(--s-7));
  margin-bottom: var(--s-9);
}

/* ---- the arch -------------------------------------------------------- */

.practice__arch {
  position: relative;
  width: 100%;
  /* Stated explicitly. `width: auto` on a full-bleed child collapses to 0 in a
     grid parent — that is how the threshold plate once shipped at width 0. */
  max-width: 430px;
  margin-inline: auto;
}
.arch__stage { position: relative; }
.arch__art {
  display: block;
  width: 100%;
  height: auto;
}

/* The four slots, positioned as percentages of the ARTWORK, measured off it
   rather than guessed: the jambs sit at x 8.2% / 93.5%, and the banner pill is
   centred on y 88.5%. Percentages, so they hold at every size the arch takes. */
.arch__slot {
  position: absolute;
  margin: 0;
  max-width: none;
  font-family: var(--mono);
  font-size: clamp(.58rem, .3rem + .5vw, .7875rem);
  /* NO text-transform. Charles specified these four strings with their casing —
     `The`, `Muffin Man`, `Coven Matriarch` — and uppercasing them in CSS
     overrides his casing on screen while the DOM (and so the copy lock) still
     says otherwise. That is the page quietly disagreeing with its own manifest. */
  letter-spacing: .07em;
  color: var(--ember);
  white-space: nowrap;
  pointer-events: none;
}
/* "The" sits ABOVE the artwork, so it is a sibling of the stage rather than a
   child of it — the art is cropped tight to the apex and has no room inside. */
.arch__slot--above {
  position: static;
  text-align: center;
  margin-bottom: var(--s-3);
}
/* The illustrated art has NO pill, so this line has nowhere to sit inside it.
   Below the artwork, mirroring "The" above — the pair now bracket the picture
   instead of being embedded in it. Positioning it over a frame that no longer
   has a slot for it would have put four words on top of her robe. */
.arch__slot--banner {
  position: static;
  text-align: center;
  margin-top: var(--s-4);
}
/* Vertical, reading in opposite directions — bottom-to-top on the left,
   top-to-bottom on the right, exactly as the annotation marks them. */
.arch__slot--left {
  left: 3.4%; top: 44%;
  writing-mode: vertical-rl;
  transform: translate(-50%, -50%) rotate(180deg);
}
.arch__slot--right {
  left: 96.6%; top: 44%;
  writing-mode: vertical-rl;
  transform: translate(-50%, -50%);
}

.notes {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  margin: 0;
  border-top: var(--rule) solid color-mix(in srgb, var(--chalk) 34%, transparent);
}
.notes > div { min-width: 0; padding: var(--s-5) var(--s-4) var(--s-6) 0; }
.notes dt {
  font-family: var(--mono);
  font-size: .7875rem;
  letter-spacing: var(--tr-utility);
  text-transform: uppercase;
  color: var(--pewter);
  margin-bottom: var(--s-4);
}
.notes dd {
  margin: 0;
  font-family: var(--display);
  font-variation-settings: "opsz" 16, "wght" 380, "SOFT" 20, "WONK" 0;
  font-size: 1rem;
  line-height: 1.35;
  color: var(--chalk);
}
@media (max-width: 899px) {
  .notes { grid-template-columns: 1fr 1fr; }
  .station--practice .display span:last-child { padding-left: 1.2em; }
}

/* ======================================================================
   3 · ALMANAC — a ruled index. Row height falls out of the type.
   Rows are centre-aligned, so differing title lengths give every row a
   different start x. The inline slot inflates on hover and the words slide
   apart to make room: layout inflation, not an overlay.
   ====================================================================== */

.almanac__head {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: end;
  gap: var(--s-5);
  margin-bottom: var(--s-9);
}
.almanac__head .util:first-child { grid-column: 1 / -1; }
.almanac__head .display { font-size: clamp(3rem, 1.4rem + 5vw, 6.25rem); }
.almanac__head .display span:last-child { padding-left: .5em; }
.almanac__note { text-align: right; }

.index {
  list-style: none;
  margin: 0;
  padding: 0;
  border-bottom: 2px solid color-mix(in srgb, var(--chalk) 30%, transparent);
}

/* THE MOON DRIVES THE ROW.
   Every entry carries --lum, the fraction of the moon lit on that night. It is
   not decoration: one number drives the rule weight, the type weight and
   softness, and how far the row opens. June 26 (full) is bright, heavy and
   wide; June 8 (dark of the moon) is a faint line, light type, nearly closed.
   Read the index with the words removed and you can still see the month.

   This is what stops the almanac being a project list with different words in
   it — the answer to "could this site have been about a different subject?" */
/* ======================================================================
   THE ALMANAC MEDIA RAIL

   izanami's projects section, measured (research/izanami/MOTION.md §3): the
   media occupies ONE fixed column — x never changes, width never changes —
   and the contents swap as each entry passes. Three sticky panels there;
   eleven cross-faded moons here, in a single sticky slot.

   The station itself is the grid, and .index stays a DIRECT CHILD of it.
   Wrapping .index and the rail in a shared container would have been the
   obvious markup and would have silently killed the reveal: entrances()
   selects `:scope > .index`, so one extra level of nesting stops eleven rows
   from ever animating in. That is trap #1, and it has already happened once
   in this project when the header moved into .plate-stage.
   ====================================================================== */

@media (min-width: 1100px) {
  .station--almanac {
    display: grid;
    /* three columns of entries, one of media — the media column is exactly one
       grid column, which is how izanami builds it and how §4 defines width. */
    grid-template-columns: minmax(0, 1fr) var(--measure);
    column-gap: var(--s-8);
  }
  .station--almanac > .plate-stage,
  .station--almanac > .almanac__credit { grid-column: 1 / -1; }
  .station--almanac > .index          { grid-column: 1; }
  .station--almanac > .almanac__rail  { grid-column: 2; }
}

.almanac__rail { position: relative; }
.rail__slot {
  position: sticky;
  /* Stated, not derived. Every child is position:absolute, so any layout mode
     that asks this box how wide it wants to be gets 0. */
  justify-self: stretch;
  /* Centred in the viewport so the moon sits beside whichever row is being
     read, rather than at the top of a very tall column. */
  top: calc(50svh - (var(--measure) / 2));
  width: 100%;
  aspect-ratio: 1;
}
.rail__moon {
  position: absolute;
  inset: 0;
  width: 100%;
  height: auto;
  opacity: 0;
  /* THE REVEAL FAMILY, and a PAIRED RATE.
     At --t-slow (0.3s) Charles read the cycle as "still sort of rapid... i
     want a more slow cinematic flow" — of the moon images specifically. Two
     corrections, neither of them an invented number:

     1. Wrong family. §5 defines two duration families and says plainly they
        are not the same thing: 0.3s governs interface RESPONSE, 0.7s governs
        CONTENT ARRIVING. A moon replacing another moon is content arriving.
     2. Wrong shape. §5 again: "Obys runs clip-path 0.8s against transform
        1.6s, so the mask finishes in exactly half the travel. Two properties,
        one gesture, deliberately different rates." The OUT is 2x the IN, the
        same ratio — so the departing moon keeps a long soft tail while the
        arriving one has already settled. A symmetric fade reads as a switch;
        an asymmetric one reads as a dissolve.
     3. Wrong curve. --ease is cubic-bezier(.16,1,.3,1) — expo-out, which
        spends most of its change in the first third. Measured on the 1.4s
        version: the outgoing moon was already down to 0.24 at 0.37s. A longer
        duration on an expo curve does not read as slower, it reads as the same
        snap with a longer tail. --ease-alt (.215,.61,.355,1) is the contract's
        SECOND sanctioned curve, it is far less front-loaded, and it has been
        defined and unused for the life of this build. M2 permits two curves;
        this is the second one, spent on the one place that needs it. */
  transition: opacity calc(var(--t-reveal) * 2.2) var(--ease-alt);
  pointer-events: none;
}
/* SEQUENTIAL, NOT SIMULTANEOUS. Both moons animating opacity at once puts them
   both near 0.5 mid-swap, and because these carry an ALPHA CHANNEL — the unlit
   limb is genuinely absent, not black — the incoming crescent cannot cover the
   outgoing full moon. You saw two moons at once. So the outgoing fades out
   first and the incoming waits exactly that long before it starts: one clean
   hand-off, never a double exposure.
   Cost is one delay. No new duration, no new curve — --t-slow and --ease, used
   twice. */
.rail__moon.is-on {
  opacity: 1;
  /* The incoming runs at 1x over the outgoing's 2x, starting half a beat in.
     --ease is expo-out, so the outgoing loses most of its opacity in the first
     third and spends the rest of its 1.4s as a very faint tail — which is
     where the incoming crosses it. The double exposure that forced sequencing
     in the first place stays under the threshold the eye reads as two moons;
     measured below. */
  transition: opacity calc(var(--t-reveal) * 1.4) var(--ease-alt);
  transition-delay: calc(var(--t-reveal) * .55);
}
/* If GSAP never loads there is no driver, so the first moon must already be
   lit in the markup — it is (`class="rail__moon is-on"`), and this keeps it
   lit rather than leaving an empty column. */
.no-gsap .rail__moon.is-on { opacity: 1; }

/* --lum drives the rail's HAIRLINE, never the moon's opacity: the moon's own
   phase already IS the lum, so fading it too would apply the same number
   twice. The rule brightens as the month fills, exactly like the row rules. */
.almanac__rail::before {
  content: "";
  position: absolute;
  inset-block: 0;
  left: calc(var(--s-6) * -1);
  width: var(--rule);
  background: color-mix(in srgb, var(--chalk)
              calc((10 + (var(--lum, .5) * 46)) * 1%), transparent);
  transition: background var(--t-reveal) var(--ease);
}

/* WHICH ROW THE MOON BELONGS TO.
   A short sodium segment laid over the row's own top rule — it borrows
   structure that is already there instead of adding a mark, the same reason
   this site uses hairlines rather than boxes. --sodium is the dock-lamp token
   and appears nowhere else in the almanac, so it reads as "you are here" and
   cannot be mistaken for the --lum ramp running down the rules.
   RIGHT edge, not left: the rail is the next thing across, so the mark lands
   in the gap between the row and the moon it belongs to and the two read as
   one statement. On the left it was a label on the wrong end of the sentence.
   No radius, no fill, no box: contract §4. */
.entry::before {
  content: "";
  position: absolute;
  top: calc(var(--rule) * -2);
  right: 0;
  width: 3.5rem;
  height: calc(var(--rule) * 2);
  /* A 56x2px hairline, not a surface — nothing is read against it, so §1a does
     not apply. P5 wants the declaration ON the line, so it is on the line. */
  background: var(--sodium); /* mark */
  opacity: 0;
  /* Same family as the moon it points at, so the mark and the picture move
     together instead of the label arriving before the thing it labels. */
  transition: opacity var(--t-reveal) var(--ease);
  pointer-events: none;
}
.entry.is-current::before { opacity: 1; }

@media (max-width: 1099px) {
  /* No room for a rail beside the rows; the moons would be 40px trinkets.
     The indicator goes with it — with no rail there is nothing to indicate. */
  .almanac__rail { display: none; }
  .entry::before { display: none; }
}

.entry {
  position: relative;
  border-top: 2px solid color-mix(in srgb,
    var(--chalk) calc((18 + (var(--lum, .5) * 62)) * 1%), transparent);
}

.entry > article {
  display: grid;
  gap: var(--s-3);
  /* the row opens with the moon: ~1.5rem dark, ~5.5rem full */
  padding-block: calc(var(--s-5) + (var(--lum, .5) * 2rem))
                 calc(var(--s-6) + (var(--lum, .5) * 2rem));
}

.entry__when {
  color: var(--pewter);
  max-width: none;
  text-align: left;
}
.entry__when span { color: color-mix(in srgb, var(--pewter) 68%, transparent); }

.entry__title {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: .28em;
  margin: 0;
  font-family: var(--display);
  /* weight and softness track the moon: 320/0 dark, 500/40 full */
  font-variation-settings:
    "opsz" 60,
    "wght" calc(320 + (var(--lum, .5) * 180)),
    "SOFT" calc(var(--lum, .5) * 40),
    "WONK" 1;
  font-size: clamp(1.6rem, .9rem + 2.2vw, 3.1rem);
  font-weight: 400;
  line-height: 1;
  letter-spacing: var(--tr-display);
  /* and so does how much light the title itself catches */
  color: color-mix(in srgb,
    var(--chalk) calc((72 + (var(--lum, .5) * 28)) * 1%), var(--pewter));
  text-align: center;
}

/* The slot is GONE from the markup.
   Spec (contract §7) is an 11px tick inflating to a 165x110 media plate, and
   the whole point is layout inflation carrying a payload. With no per-entry
   plate to put in it, what shipped was an 11px -> 232x1px hairline that read as
   a stray en-dash inside display type, and moved 110px of type to make room for
   nothing — which also contradicts "display type does not move".
   An empty gesture is worse than no gesture. If per-entry plates are ever
   generated, restore it to spec rather than to what was here. */

.entry__body {
  justify-self: center;
  max-width: var(--measure);
  text-align: center;
  color: var(--pewter);
}
.entry__link { justify-self: center; color: var(--moonspill); }

/* The NASA credit. Small, quiet, and set below the closing rule of the index
   so it reads as a footnote to the whole table rather than to the last entry.
   No new duration, no new curve — it inherits the utility style. */
.almanac__credit {
  /* .util carries a reading-measure cap, which pinned this to 324px at the
     left margin and made `text-align: right` right-align inside a narrow box.
     The credit is a footnote to the whole table, so it needs the table's width. */
  max-width: none;
  margin-top: var(--s-4);
  text-align: right;
  color: color-mix(in srgb, var(--pewter) 62%, transparent);
  font-size: .6875rem;
  letter-spacing: .08em;
}
@media (max-width: 899px) { .almanac__credit { text-align: left; } }

@media (max-width: 899px) {
  /* mobile drops the centring: rows go left-ranged so a ragged edge does not
     fight a narrow measure, and the slot becomes a full-width rule. */
  .entry__when { text-align: left; }
  .entry__title { justify-content: flex-start; text-align: left; }
  .entry__body { justify-self: start; text-align: left; }
  .entry__link { justify-self: start; }
  /* No hover on touch, so the slot cannot inflate. But `display: none` deleted
     the element that was carrying the word space inside split titles, which
     produced "Moon water,small batch" on every phone. Collapse it to nothing
     and keep the space. */
}

/* ======================================================================
   4 · SIGIL
   ====================================================================== */

.station--sigil .display {
  font-size: clamp(2.4rem, 1.2rem + 3.4vw, 4rem);
  margin-bottom: var(--s-6);
}

.sigil__body { margin-bottom: var(--s-8); color: var(--pewter); }

.sigil__stage { display: grid; justify-items: center; gap: var(--s-5); }
#sigil-canvas {
  width: min(34rem, 100%);
  height: auto;
  aspect-ratio: 1;
  cursor: crosshair;
  touch-action: none;
  /* corner ticks, not a box. A full border made this the second card-like
     object on a hairlines-and-no-fills site, and it read as an empty frame. */
  border: 0;
  background-image:
    linear-gradient(var(--chalk), var(--chalk)), linear-gradient(var(--chalk), var(--chalk)),
    linear-gradient(var(--chalk), var(--chalk)), linear-gradient(var(--chalk), var(--chalk));
  background-repeat: no-repeat;
  /* all four corners, two strokes each: eight gradients */
  background-image:
    linear-gradient(var(--chalk), var(--chalk)), linear-gradient(var(--chalk), var(--chalk)),
    linear-gradient(var(--chalk), var(--chalk)), linear-gradient(var(--chalk), var(--chalk)),
    linear-gradient(var(--chalk), var(--chalk)), linear-gradient(var(--chalk), var(--chalk)),
    linear-gradient(var(--chalk), var(--chalk)), linear-gradient(var(--chalk), var(--chalk));
  background-size:
    1.4rem 1px, 1px 1.4rem, 1.4rem 1px, 1px 1.4rem,
    1.4rem 1px, 1px 1.4rem, 1.4rem 1px, 1px 1.4rem;
  background-position:
    left top, left top, right top, right top,
    left bottom, left bottom, right bottom, right bottom;
  opacity: .92;
  transition: opacity var(--t-base) var(--ease);
}
#sigil-canvas:hover,
#sigil-canvas:focus-visible { opacity: 1; }
.sigil__hint { text-align: center; max-width: 44ch; }

/* ======================================================================
   5 · THRESHOLD — the one place display type is monumental.
   ====================================================================== */

.station--threshold {
  min-height: 92svh;
  display: grid;
  align-content: center;
  justify-items: center;
  text-align: center;
}
.threshold__line {
  font-size: clamp(2.6rem, 1rem + 5.4vw, 6.5rem);
  max-width: 14ch;
  margin-bottom: var(--s-8);
}
.threshold__door {
  /* a peer of the line above it, not a button. The bordered box was the only
     card-like object on a site whose contract is hairlines and no fills. */
  font-family: var(--display);
  font-variation-settings: "opsz" 40, "wght" 360, "SOFT" 30, "WONK" 1;
  font-size: clamp(1.4rem, .9rem + 1.4vw, 2.1rem);
  letter-spacing: var(--tr-mid);
  color: var(--chalk);
  padding-bottom: var(--s-2);
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% var(--rule);
  transition: color var(--t-base) var(--ease);
}
.threshold__door:hover { color: var(--sodium); }

/* ======================================================================
   6 · CLOSING
   ====================================================================== */

.station--closing { padding-bottom: var(--s-9); }

.closing__line {
  font-size: clamp(2rem, 1rem + 3.4vw, 4rem);
  max-width: 12ch;
  margin-bottom: var(--s-9);
}
.closing__line em {
  font-style: italic;
  font-variation-settings: "opsz" 60, "wght" 340, "SOFT" 40, "WONK" 1;
}

.closing__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(var(--measure), 1fr));
  gap: var(--s-6) var(--s-7);
  padding-top: var(--s-5);
  border-top: var(--rule) solid color-mix(in srgb, var(--chalk) 30%, transparent);
  margin-bottom: var(--s-8);
  text-transform: none;
}
.closing__tel span { color: var(--chalk); }
.closing__links { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s-3); }
.closing__note {
  max-width: calc(var(--measure) * 2);
  color: color-mix(in srgb, var(--pewter) 82%, transparent);
  text-transform: none;
}

/* closing grid: inline children laid out as columns without becoming blocks */
.closing__grid { display: flex; flex-wrap: wrap; max-width: none; }
.closing__cell { flex: 1 1 var(--measure); min-width: 0; }
.closing__links { display: flex; flex-direction: column; gap: var(--s-3); }


/* ======================================================================
   SHORT AND ULTRAWIDE VIEWPORTS
   Charles's own window is ~2228 x 510. At that shape a 100svh hero with
   centred content crushes everything into the top-left and the plate becomes
   a thin band. Height, not width, is the constraint — so the query is on
   height. This is the viewport the whole verification suite was blind to.
   ====================================================================== */

@media (max-height: 700px) and (min-width: 900px) {
  /* Short window slices a ~30% band out of a 4:3 plate. Sit that band a
     little high so the moon (composed at ~1/3 down) is in frame at rest and
     the waterline arrives as the plate travels. The plate carries its own
     reserve now, so this is a nudge, not a rescue. */
  .plate img { object-position: 50% 46%; }

  /* Short window: more runway so the exit still takes real time. Only
     --runway changes; min-height, travel and reserve all follow, so the
     0.19 ratio holds here too. */
  .plate-stage { --runway: 110svh; }
  .station--hero > .station__over {
    align-content: end;
    padding-bottom: var(--s-7);
  }
  .hero__mark   { font-size: 1rem; letter-spacing: .26em; }
  .hero__intro  { margin-bottom: var(--s-5); }
  .hero__line   { font-size: 1.375rem; max-width: 22ch; }
  .tonight      { bottom: var(--s-6); }
  .tonight > div:not(.tonight__head) { padding: var(--s-2) 0; }
  .cue          { bottom: var(--s-3); }
  /* NOT bare `.station`. That selector has the same specificity as
     `.station--hero { padding-block: 0 }` and comes later, so it won the
     cascade and gave the hero 64.8px of padding it is explicitly meant not to
     have. The overlay was pushed down by exactly that much and its bottom
     64.8px sat below the fold — which is where the wordmark now lives, so it
     shipped clipped at 2228x510, Charles's real window.
     It also inflated the hero station by 129.6px, which is travel the 0.19x
     parallax ratio has to absorb (LEDGER D17 recorded 0.155 here and blamed
     travel/height drift). Ratio re-measured after this change. */
  .station:not(.station--hero) { padding-block: var(--s-8); }

  /* The arch is ONE composed image — portrait, frame, rays, banner. At 2228x510
     it measured 727px tall in a 510px window, so it could never be seen whole,
     and half an arch is worse than a small whole one. Cap it off viewport
     height, the same move the wordmark uses. 804/1360 is the SVG's own aspect.

     Capping the COLUMN, not the element. `max-width` on .practice__arch does
     nothing here: the ultrawide rule `.station > *:not(.plate):not(.plate-stage)`
     scores 0,2,1 and overrides a bare 0,1,0 class with `max-width: 1800px`, and
     the arch's real width was coming from the grid track regardless. Measured,
     not assumed — computed max-width read back as 1800px. */
  .station--practice {
    grid-template-columns: minmax(0, 1fr) min(calc(var(--measure) * 1.45), calc(88svh * 0.7467));
  }
}

/* very wide: hold the plate's own aspect rather than cropping it to a sliver */
@media (min-width: 1900px) {
  .plate img { object-position: 50% 52%; }
  /* Clamp the CONTENT, do not widen the station's padding to do it.
     Padding must stay a plain length, because .plate cancels it with a negative
     margin and `calc((100% - 1800px)/2)` resolves 100% against a DIFFERENT box
     in the child than in the parent — so the plate cancelled 72px where the
     station had applied 364px, and the hero kept its dark bars.

     THE CEILING WAS 1800px AND THAT WAS THE OTHER HALF OF THE EMPTINESS. The
     margins now scale with the window (tokens.css), so this clamp was fighting
     them: at 2309 it threw away 279px of content width the grid had just
     earned, and the page read as a 1800px design stranded in the middle of a
     wide screen. It stays only as a far-out safety stop so a 3840px monitor
     does not get 800px columns. */
  .station > *:not(.plate):not(.plate-stage),
  .plate-stage > .station__over {
    max-width: 2560px;
    margin-inline: auto;
  }

  /* ...EXCEPT on a grid station, where that rule is actively destructive.
     `margin-inline: auto` on a GRID ITEM centres it inside its own track. The
     practice station is a two-track grid, so every child — kicker, heading,
     copy — was being centred in the 1fr column instead of starting at the
     margin. Measured at 2309: the kicker began 766px from the left edge with
     nothing to its left. THAT is the "waiting to be filled" space, and it was
     never the grid formula.
     Clamp the station itself instead. Safe here because the practice station
     is the one wide station with no .plate inside it, so there is no negative
     margin to fight (which is why the children were targeted originally). */
  .station--practice,
  .station--almanac { max-width: 2560px; margin-inline: auto; }
  /* EVERY GRID STATION, not just the practice one. `margin-inline: auto` on a
     grid item stops it stretching to its track and makes it shrink-to-fit —
     and the almanac rail's only content is absolutely positioned, so
     fit-content is literally ZERO. Measured at 2318x770: the rail computed to
     width 0 at x=1961, which is the track's centre. The moons had loaded, the
     driver had lit the right one, opacity was 1. There was simply no column.

     This is the THIRD silent collapse from the same family: the threshold
     plate at `width: auto` in a flex parent, the practice grid items centred
     in their tracks, and now this. Specificity has to MATCH, not merely
     exist — `:not()` contributes its argument's weight twice, so the rule
     above scores (0,3,0) and a bare `.station--x > *` at (0,1,0) loses. */
  .station--practice > *:not(.plate):not(.plate-stage),
  .station--almanac > *:not(.plate):not(.plate-stage) {
    max-width: none;
    margin-inline: 0;
  }
}
