/**
 * Orbit Deep Space — the stylesheet.
 *
 * Values come from tokens.css. The only literals here are the structural
 * constants allowlisted in DESIGN.md §11; anything else is a bug.
 *
 * Read it in this order:
 *   1. faces, reset, base type
 *   2. shared primitives (scene chrome, features, links, media)
 *   3. the seven scenes
 *   4. the gate and the page shell
 *   5. STATIC MODE is the default; SCENE MODE is the enhancement below it
 *   6. choreography and reduced motion
 */

/* ------------------------------------------------------------------ faces -- */

@font-face {
  font-family: "Syne";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/assets/fonts/syne-latin.woff2") format("woff2");
}

@font-face {
  font-family: "Syne";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/assets/fonts/syne-latin.woff2") format("woff2");
}

@font-face {
  font-family: "DM Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/assets/fonts/dm-sans-400.woff2") format("woff2");
}

@font-face {
  font-family: "DM Sans";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/assets/fonts/dm-sans-500.woff2") format("woff2");
}

/* ------------------------------------------------------------------ reset -- */

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

html {
  background: var(--canvas);
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--step-1);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

h1,
h2,
h3,
h4,
p,
ul,
ol,
figure {
  margin: 0;
}

ul,
ol {
  padding: 0;
  list-style: none;
}

img {
  display: block;
  max-width: 100%;
  height: auto;
}

/* ------------------------------------------------------------- base links -- */

a {
  color: var(--accent);
  text-decoration: none;
  border-bottom: var(--rule-width) solid var(--accent-line);
  transition: color var(--transition), border-color var(--transition), background-color var(--transition);
}

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

:focus-visible {
  outline: var(--focus-ring);
  outline-offset: 2px;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 10;
  padding: var(--space-3) var(--space-4);
  background: var(--accent);
  color: var(--accent-ink);
  border: 0;
  font-size: var(--step-0);
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

.skip-link:focus {
  left: var(--space-4);
  top: var(--space-4);
}

/* -------------------------------------------------------------- type bits -- */

.eyebrow,
.scene__eyebrow,
.scene__aside,
.scene__label,
.feature__meta,
.secondary__meta,
.finale__meta {
  font-size: var(--step--1);
  line-height: 1;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* Scenes 2 and 3 label themselves with a real <h2> so the document outline is
   h1 → h2 per scene. It is styled as an eyebrow, not as a heading. */
.scene__label {
  font-weight: 500;
}

/* The secondary column fills from 200px; at that width the tracked meta row
   breaks between the count and its unit, so one fact reads as two. */
.meta__stars {
  white-space: nowrap;
}

.dot {
  width: var(--space-2);
  height: var(--space-2);
  flex: none;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 14px var(--accent-halo);
}

.prose p + p {
  margin-top: var(--space-4);
}

.prose a {
  text-underline-offset: 0.2em;
}

/* ---------------------------------------------------------- scene chrome -- */

.scene__eyebrow {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3) var(--space-4);
  margin-bottom: var(--space-5);
}

.scene__eyebrow--centred {
  justify-content: center;
  text-align: center;
  margin-bottom: var(--space-6);
}

.scene__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3) var(--space-4);
  padding-bottom: var(--space-3);
  border-bottom: var(--rule-width) solid var(--rule);
}

.scene__title {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--step--1);
  line-height: 1;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--ink);
}

.scene__aside {
  margin-left: auto;
  letter-spacing: 0.2em;
}

.scene__body {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(18px, 3.4vw, 44px);
  padding-top: clamp(16px, 2.6vw, 28px);
  align-items: flex-start;
}

/* -------------------------------------------------------- feature column -- */

.feature {
  flex: 1 1 300px;
  min-width: 0;
}

/* The act names are in this rule because they ARE this type: Code and Music
   share a layout, so what fills the left column of one is what fills the other. */
.feature__title,
.acts__label {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--step-3);
  line-height: 1;
  letter-spacing: -0.03em;
}

.feature__title a {
  color: inherit;
  border: 0;
}

.feature__title a:hover {
  color: var(--accent);
}

.feature__meta {
  margin: var(--space-3) 0;
  letter-spacing: 0.2em;
  color: var(--accent);
}

.feature__lead {
  max-width: var(--measure);
  font-size: var(--step-1);
  line-height: 1.55;
  color: var(--ink-soft);
}

.links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  margin-top: var(--space-4);
  font-size: var(--step-0);
}

/* The underline stays — it is what marks these as links, and deleting it to
   quiet the page would trade an affordance for tidiness. Taken one rung down
   the ladder instead, so it reads as an underline rather than as another rule. */
.links__item {
  color: var(--ink-muted);
  border-bottom-color: var(--rule-soft);
}

.links__item:hover {
  color: var(--accent);
  border-color: var(--accent);
}

/* ------------------------------------------------------ secondary column -- */

.secondary {
  flex: 1 1 260px;
  min-width: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: var(--space-6) var(--space-5);
  align-content: start;
}

/* Same trade as the release rows: the separator is vertical space, not a line.
   The row gap on .secondary above is what pays for the removed rule. */
.secondary__item {
  padding-top: 0;
}

.secondary__title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--step-2);
  line-height: 1.3;
}

.secondary__title a {
  color: inherit;
  border: 0;
}

.secondary__title a:hover {
  color: var(--accent);
}

.secondary__meta {
  margin: var(--space-1) 0 var(--space-2);
  letter-spacing: 0.18em;
}

.secondary__lead {
  font-size: var(--step-0);
  line-height: 1.5;
  color: var(--ink-muted);
}

/* -------------------------------------------------------------- 1 · hero -- */

.hero {
  display: grid;
  justify-items: center;
  text-align: center;
  gap: clamp(10px, 2vh, 20px);
}

.hero__eyebrow {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  letter-spacing: 0.42em;
  color: var(--ink-muted);
}

.hero__name {
  width: 100%;
  padding: clamp(6px, 1.4vh, 16px) 0;
  border-top: var(--rule-width) solid var(--rule);
  border-bottom: var(--rule-width) solid var(--rule);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--step-4);
  line-height: 0.9;
  letter-spacing: -0.05em;
  text-transform: uppercase;
  /* White, lit rather than coloured: a close white bloom and a wide accent one.
     The name is the brightest thing on the page and the accent stays a signal. */
  color: #ffffff;
  text-shadow: 0 0 18px var(--bloom-white), 0 0 60px var(--accent-bloom);
}

.hero__line {
  max-width: 52ch;
  font-size: var(--step-1);
  line-height: 1.55;
  color: var(--ink-soft);
}

/* ------------------------------------------------------ the media surface -- */

/* All that survives the parked 3D lane: the music covers sit on this surface
   too, which is why --media-surface stays in the token contract. */
.media {
  background: var(--media-surface);
  border: var(--rule-width) solid var(--rule-soft);
}

/* ------------------------------------------------------------- 6 · music -- */

/* The act selector. Why it is a radio group and how its markup is ordered is
   written once, in partials/scene-music.njk — read that first; this is only how
   it is placed.

   Music takes the Code scene's two columns, so `.scene__body` still lays this
   section out and the gap and top padding are shared between the two scenes.
   Only the display type changes, which is why this rule sits after it in the
   cascade rather than carrying its own copy of it. The rest undoes what a
   `<fieldset>` costs — a UA border and inline padding — and `position: relative`
   keeps the clipped radios' containing block inside this section, so focusing
   one cannot scroll the pinned stage somewhere else. */
.acts {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
  min-inline-size: 0;
  margin: 0;
  border: 0;
  padding-inline: 0;
  padding-bottom: 0;
}

/* Generates no box, so the wrapper the template needs to keep an act's three
   elements together as siblings does not become a grid cell they all share. */
.acts__act {
  display: contents;
}

/* Clipped, NOT `display: none` and NOT `hidden`. Either of those would take the
   radio out of the accessibility tree and off the tab ring, which is the whole
   control — the visible label is only its click target. */
.acts__legend,
.acts__pick {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The picked act is at full ink and the rest are dimmed, and the rule down the
   left edge says which. It is drawn on every label and merely transparent when
   the act is not picked, so picking one cannot shift the column sideways. */
.acts__label {
  grid-column: 1;
  padding-left: var(--space-4);
  border-left: var(--rule-width) solid transparent;
  color: var(--ink-dim);
  cursor: pointer;
  transition: color var(--transition), border-color var(--transition);
}

.acts__label:hover {
  color: var(--accent);
}

.acts__pick:checked + .acts__label {
  border-left-color: var(--accent);
  color: var(--ink);
}

/* The ring belongs on the label, because the radio itself is one clipped pixel
   and a ring around that would tell a keyboard visitor nothing. */
.acts__pick:focus-visible + .acts__label {
  outline: var(--focus-ring);
  outline-offset: 2px;
}

/* Every panel is placed in the same cell — column 2, the full height of the
   rail — and only the picked act's is displayed, so switching acts never moves
   the column. The row track the template writes is what keeps the act names
   stacked at the top: the panel spans a flexible last row, which takes whatever
   height the names do not need. */
.acts__panel {
  display: none;
  grid-column: 2;
  grid-row: 1 / -1;
}

.acts__pick:checked + .acts__label + .acts__panel {
  display: block;
}

/* Below this width neither column can hold a release row, so the act list
   becomes a strip above the panel rather than a column beside it. The selector
   STAYS on a phone: a phone is a scene-mode device and `.pin` clips what does
   not fit, so stacking every act's releases at once would put rows out of
   reach — the opposite of what a small viewport needs. */
@media (max-width: 640px) {
  .acts {
    display: flex;
    flex-wrap: wrap;
  }

  /* A strip is read across, not down, so the names drop to the size the rest of
     the scene's titles use rather than staying at the display step. */
  .acts__label {
    font-size: var(--step-2);
    line-height: 1.3;
  }

  .acts__panel {
    order: 1;
    flex-basis: 100%;
  }
}

/* No delimiter between rows. Four kinds of hairline were competing in this
   one section — the ambient rings, the scene rule, a rule per row and the link
   underlines — and the row rules were the ones carrying no information the
   spacing could not carry instead. Removing a rule costs separation, so the
   gap grows to pay for it: without the line the rows need MORE air, not the
   same amount. The scene header rule stays; it marks the section, not a row. */
.releases {
  display: grid;
  gap: var(--space-6);
  margin-top: var(--space-5);
}

.releases__item {
  display: flex;
  gap: var(--space-4);
  align-items: flex-start;
}

.releases__cover {
  width: 84px;
  flex: none;
  aspect-ratio: 1;
}

.releases__body {
  min-width: 0;
}

.releases__title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--step-2);
  line-height: 1.3;
}

.releases__meta {
  margin: var(--space-1) 0 var(--space-2);
  font-size: var(--step-0);
  color: var(--ink-muted);
}

.releases .links {
  margin-top: var(--space-2);
}

/* The facade is ours, so it is drawn in our own tokens rather than as a
   borrowed widget. It is also the only pill in the row: every platform the
   release links to, Spotify included, is a plain secondary label underneath.
   It used to sit beside a bordered "Listen" pill pointing at the same Spotify
   URL, which read as two controls for one destination. What replaces this on
   click is not ours: the player is an opaque frame and the only thing we style
   is the box it sits in. */
.player {
  margin-top: var(--space-4);
}

.player__play {
  display: inline-block;
  padding: var(--space-1) var(--space-3);
  border: var(--rule-width) solid var(--accent-line);
  border-radius: var(--radius);
}

.player__play:hover {
  background: var(--accent-wash);
  border-color: var(--accent);
  color: var(--accent);
}

/* 152px is the compact player height and is right for both the track and the
   album form, so neither has to be special-cased. */
.player__embed {
  display: block;
  width: 100%;
  max-width: 420px;
  height: 152px;
  margin-top: var(--space-2);
  border: 0;
}

/* ------------------------------------------------------------ 7 · finale -- */

.finale {
  display: grid;
  justify-items: center;
  text-align: center;
}

.finale__figure {
  position: relative;
  display: flex;
  justify-content: center;
  width: 100%;
}

.portrait {
  position: relative;
  display: inline-block;
}

.portrait__img {
  position: relative;
  height: min(42vh, 360px);
  width: auto;
  max-width: 64vw;
  object-fit: contain;
  object-position: bottom;
  /* Barely desaturated and slightly over-saturated in what remains, so the
     portrait keeps its warmth against the blue rather than going to tin. */
  filter: grayscale(0.28) saturate(1.15) contrast(1.06) brightness(0.94);
  mask-image: linear-gradient(to bottom, #000 40%, rgba(0, 0, 0, 0.3) 74%, transparent 96%);
  -webkit-mask-image: linear-gradient(to bottom, #000 40%, rgba(0, 0, 0, 0.3) 74%, transparent 96%);
}

.finale__reveal {
  position: relative;
  margin-top: clamp(-72px, -7vh, -28px);
  display: grid;
  justify-items: center;
  gap: clamp(10px, 1.8vh, 18px);
}

.finale__head {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--step-3);
  line-height: 1;
  letter-spacing: -0.035em;
  text-transform: uppercase;
}

.finale__rule {
  width: clamp(80px, 14vw, 160px);
  height: var(--rule-width);
  background: var(--accent);
}

.finale__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-5);
  justify-content: center;
  font-size: var(--step-1);
}

.finale__sep {
  color: var(--ink-dim);
}

.finale__meta {
  letter-spacing: 0.24em;
  color: var(--ink-muted);
}

.finale__meta a {
  color: var(--ink-muted);
  border-bottom-color: var(--rule-strong);
}

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

/* ------------------------------------------------------- decorative bits -- */

/* Ambient layers exist in the HTML but are inert until scene mode turns them on. */
.stars,
.rings,
.beam,
.smoke,
.burst,
.steam {
  display: none;
}

/* ----------------------------------------------------------------- gate --- */

/* The gate is a scene-mode device: it exists to hold the page shut until the
   visitor presses it, and only scene mode ever locks scrolling. In static mode
   it would sit at the bottom of the document as a button whose only effect is
   to hide itself — a dead control and a pointless tab stop. So it ships hidden
   and scene mode turns it on. No-JS visitors never see it. */
.gate {
  display: none;
  place-items: center;
}

/* Bounded on both axes, because the gate is square and a square sized off one
   axis overflows the other. 74vw keeps it clear of the side edges on a phone;
   74svh does the same for the top and bottom, which is what a landscape phone
   needs and what a width-only cap gets wrong; 520px is where it stops growing
   on a desktop.

   The 290px cap this replaces was set while the ambient orbit was still drawn
   behind the gate — those rings ran 538px and 365px and were supplying the
   composition's scale. Suppressing them while the gate is shut left a 290px
   gate alone in a 1280x860 viewport, reading small and lost. The gate now
   carries the frame the rings used to. It resolves to 289 at 390x844 and at
   844x390, so no phone moves; it doubles on a desktop, which is the only place
   the old cap was ever binding. */
.gate__seal {
  position: relative;
  width: min(74vw, 74svh, 520px);
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  padding: 0;
  background: transparent;
  border: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

.gate__seal:hover {
  filter: brightness(1.3);
}

.gate__nebula,
.gate__bloom,
.gate__pulse,
.gate__spin,
.gate__diamond {
  position: absolute;
  pointer-events: none;
}

/* The seal stays a square; only the soft layers are round. */
.gate__nebula,
.gate__bloom {
  border-radius: 50%;
}

/* Three soft layers behind the rings: a wide accent cloud, a cooler drift
   across it, and a slow white bloom at the centre. They are what makes the
   gate read as depth rather than as a flat badge. */
.gate__nebula--a {
  inset: -70%;
  background: radial-gradient(closest-side, var(--accent-nebula), var(--accent-nebula-far) 55%, transparent 78%);
  filter: blur(28px);
  animation: nebulaA 24s ease-in-out infinite alternate;
}

.gate__nebula--b {
  inset: -45%;
  background: radial-gradient(closest-side at 62% 38%, var(--accent-nebula-cool), transparent 70%);
  filter: blur(34px);
  animation: nebulaB 31s ease-in-out infinite alternate;
}

.gate__bloom {
  inset: 18%;
  background: radial-gradient(closest-side, var(--ink-veil), transparent 72%);
  filter: blur(18px);
  animation: glowPulse 6s ease-in-out infinite alternate;
}

.gate__pulse {
  inset: 0;
  border: var(--rule-width) solid var(--rule);
  border-radius: 50%;
  animation: gatePulse 4.5s ease-in-out infinite alternate;
}

.gate__spin {
  inset: 13%;
  border: var(--rule-width) dashed var(--accent-ring);
  border-radius: 50%;
  animation: gateSpin 26s linear infinite;
}

.gate__diamond {
  inset: 27%;
  border: var(--rule-width) solid var(--rule-strong);
  transform: rotate(45deg);
  transition: transform 1s cubic-bezier(0.2, 0.7, 0.2, 1), opacity 0.7s ease;
}

/* The monogram is the seal's face. It sits above the rings in the normal flow
   of the grid, and on entry it scales out towards the viewer rather than
   unlatching — the gate opens by receding, not by unlocking.

   Sized off the seal's own three drivers rather than the viewport's, so it
   holds one proportion inside the ring stack at every size instead of drifting
   as the seal grows: a fifth of the seal, which is what the desktop gate
   already was before the seal was uncapped. The clamp this replaces was pinned
   to the viewport instead, so uncapping the seal to 520 would have left a 58px
   monogram adrift in it. */
.gate__logo {
  position: relative;
  width: min(14.8vw, 14.8svh, 104px);
  height: auto;
  color: var(--ink);
  /* The at-rest glow, and the value reduced-motion visitors keep. */
  filter: drop-shadow(0 0 10px var(--ink-glow)) drop-shadow(0 0 34px var(--accent-aura));
  /* Only the filter is animated. The transform belongs to the entry
     transition below, which scales the monogram out as the gate recedes —
     a keyframe touching transform would fight it. */
  animation: logoGlow 3.6s ease-in-out infinite alternate;
  transition: transform 0.8s cubic-bezier(0.2, 0.7, 0.2, 1), opacity 0.6s ease;
}

.gate__hint {
  position: absolute;
  bottom: -13%;
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: var(--step--1);
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

/* ----------------------------------------------------------------- mark --- */

/* The site mark: the monogram, pinned to the top-right corner of every page.
   It is decoration with a name, never a control — pointer-events are off and
   there is no link under it, so it costs nothing in the tab order.

   Visible by default. That covers static mode, stacked mode, no-JS and every
   page that is not the home page, where it simply is the site's mark. Scene
   mode is the one place it hides, because there the gate is still closed and
   the corner belongs to nothing yet. z-index 9 sits under the skip link's 10,
   and the two live in opposite corners regardless. */
.mark {
  position: fixed;
  top: clamp(14px, 2.4vw, 26px);
  right: clamp(14px, 2.4vw, 26px);
  width: clamp(16px, 1.8vw, 22px);
  height: auto;
  color: var(--ink);
  opacity: 0.92;
  pointer-events: none;
  z-index: 9;
  /* The one place the mark's reveal timing is written down. In scene mode it
     starts hidden and this is what brings it in on entry; everywhere else the
     mark never changes opacity, so the value simply never fires.

     0.15s is the moment the scan sweep's light clears the top-right corner —
     the mark is the first thing the sweep passes, so it is the first thing the
     page puts down, and it is visibly arriving while the entry is still
     playing rather than after it. The curve is the one the rest of the entry
     uses, which front-loads the fade: the mark is halfway up by 200ms and
     settled by 450ms, against an entry that runs past 1.2s. */
  transition: opacity 0.3s cubic-bezier(0.2, 0.7, 0.2, 1) 0.15s;
}

/* ------------------------------------------------------------ page shell -- */

.page {
  width: 100%;
  max-width: 1000px;
  margin: 0 auto;
  padding: var(--space-8) clamp(18px, 4vw, 40px) var(--space-7);
}

.crumb {
  margin-bottom: var(--space-7);
  font-size: var(--step--1);
  letter-spacing: 0.24em;
  text-transform: uppercase;
}

.crumb a {
  color: var(--ink-muted);
  border-bottom-color: var(--rule-strong);
}

.crumb a:hover {
  color: var(--accent);
  border-color: var(--accent);
}

.page__title {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--step-3);
  line-height: 1;
  letter-spacing: -0.03em;
}

.page__tagline {
  margin-top: var(--space-4);
  max-width: var(--measure);
  font-size: var(--step-2);
  line-height: 1.3;
  color: var(--ink-soft);
}

.page__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-4);
  margin-top: var(--space-5);
  padding-bottom: var(--space-5);
  border-bottom: var(--rule-width) solid var(--rule);
  font-size: var(--step--1);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.page__meta .chip {
  color: var(--accent);
}

.page .prose {
  max-width: var(--measure);
  margin-top: var(--space-6);
  color: var(--ink-soft);
}

.page .prose h2,
.page .prose h3 {
  margin-top: var(--space-6);
  margin-bottom: var(--space-3);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--step-2);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--ink);
}

/* Markdown bodies emit arbitrary sequences of p, ul, h2 and code blocks, so the
   rhythm comes from a flow rule rather than from enumerating every pairing.
   Headings override it with their own larger margin. */
.page .prose > * + * {
  margin-top: var(--space-4);
}

.page .prose ul {
  display: grid;
  gap: var(--space-2);
}

.page .prose li {
  padding-left: var(--space-4);
  border-left: var(--rule-width) solid var(--rule-soft);
}

.page .prose code {
  padding: 0 var(--space-1);
  background: var(--media-surface);
  color: var(--ink);
}

.page .prose address {
  font-style: normal;
}

.page .prose dl {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-2) var(--space-4);
  margin-top: var(--space-4);
}

.page .prose dt {
  color: var(--ink-faint);
}

.page .prose dd {
  margin: 0;
}

.page .prose section {
  margin-top: var(--space-6);
}

.notfound {
  min-height: 60vh;
  display: grid;
  align-content: center;
  justify-items: start;
  gap: var(--space-5);
}

.notfound__code {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--step-3);
  line-height: 0.9;
  letter-spacing: -0.05em;
  color: var(--accent);
}

/* ----------------------------------------------------------- site footer -- */

.site-footer {
  max-width: 1000px;
  margin: 0 auto;
  padding: var(--space-7) clamp(18px, 4vw, 40px) var(--space-8);
  border-top: var(--rule-width) solid var(--rule-soft);
  display: grid;
  gap: var(--space-3);
}

.site-footer__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  font-size: var(--step-0);
}

.site-footer__sep {
  color: var(--ink-dim);
}

.site-footer__meta {
  font-size: var(--step--1);
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* =========================================================================
   STATIC MODE — the no-JS and reduced-motion path, and the canonical
   accessibility path. Since decision 11 it is no longer what a NARROW
   viewport gets: scene mode runs at every width, and a portrait phone is a
   scene-mode device. It is still what a SHORT viewport gets, because a scene
   does not fit in one. Scenes are ordinary stacked sections.
   Nothing below this block is required for the page to be complete and
   readable, and none of it may be deleted — it is the path for everyone the
   enhancement is not safe for.
   ========================================================================= */

.stage {
  position: relative;
}

.scenes {
  width: 100%;
  max-width: 1000px;
  margin: 0 auto;
  padding: 0 clamp(18px, 4vw, 40px);
  display: grid;
  gap: var(--space-8);
}

.scene {
  scroll-margin-top: var(--space-6);
  /* A grid item's default `min-width: auto` is its content's intrinsic width, so
     one wide flex row inside a scene pushes the whole scene past its track and
     eats the right gutter — measured at 390px, where a 372px music row overflowed
     a 354px track and clipped its own labels. The scenes grid is the only thing
     holding the page margin; it has to be allowed to win. */
  min-width: 0;
}

.scene--hero {
  padding-top: var(--space-7);
}

/* The opacity windows. Declared outside the scene-mode media query because
   scene.js reads them back to turn an in-page anchor into a scroll position.

   RE-DERIVED FOR FOUR SCENES (spec §7). Not re-pointed: the seven-scene values
   were fade windows on a track 760 viewport-heights tall, and keeping any of
   them leaves the visitor scrolling through the gaps where NOW, the lanes and
   3D used to be. (The old height is spelled out in words because the P1
   acceptance block greps this file for the literal unit.)

   The derivation, so the next reader can check it rather than trust it: each
   scene owns 1/4 of the track and is inset by .01 at each edge, which leaves
   the .02 of dark between consecutive scenes that the seven-scene version had.
   The hero is opaque from the top (--in: -1); the finale never fades out
   (--out: 2) and keeps its faster --rate: 40. At --rate: 33 the ramp is 1/33
   of the track, so each middle scene holds fully opaque for .169 of it —
   about 74svh of the 440svh stage, against 60vh in the seven-scene version.
   Slightly more room per scene, on purpose: there are three fewer of them.

   test/page-structure.test.mjs derives this whole table from the scene list in
   index.njk. Hand-tuning one value without re-running it is how the two drift. */

.scene--hero {
  --in: -1;
  --out: 0.24;
}

.scene--code {
  --in: 0.26;
  --out: 0.49;
}

.scene--music {
  --in: 0.51;
  --out: 0.74;
}

.scene--finale {
  --in: 0.76;
  --out: 2;
  --rate: 40;
}

/* =========================================================================
   SCENE MODE — the design, and as of decision 11 it is not a desktop
   privilege: a portrait phone is a first-class scene-mode device, so the
   width condition is gone and does not come back. What the measurement put
   in its place is a HEIGHT condition. The scenes are authored for a tall
   viewport and the pin below is `overflow: hidden`, so anything that does
   not fit is clipped where no amount of scrolling reaches it — on a
   landscape phone the music scene loses about 126px of release rows that
   way. So the gate asks two questions: is the viewport tall enough to hold
   a scene, and has the visitor left motion alone. Static mode below answers
   for everyone else and remains the canonical accessibility path.
   ========================================================================= */
@media (min-height: 521px) and (prefers-reduced-motion: no-preference) {
  html[data-js] .stage {
    --p: 0;
    /* svh, NOT vh and NOT dvh.
       `vh` resolves to the LARGE viewport on iOS Safari, so a pin sized in it
       is taller than what you can actually see while the address bar is up.
       `dvh` TRACKS the address bar, so a sticky pin sized in it resizes
       mid-scroll — and because scene.js derives --p from
       (stage height - pin height), a resizing pin makes the scrub jump as
       well as the layout. `svh` is fixed at the bars-shown height and never
       moves, which is the property a pinned stage needs.
       The bare-vh line is the fallback for browsers without svh; the svh line
       wins wherever it is understood. */
    height: 440vh;
    height: 440svh;
  }

  html[data-js] .pin {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100svh;
    /* There is no minimum-height floor here and there must not be one. The
       floor that used to sit here — five hundred and twenty, a desktop-era
       belt-and-braces inherited from the old 821×521 gate — is now dead
       weight twice over: the height condition on the enclosing media query
       already guarantees a taller viewport than it, and on any viewport it
       did fire it would push the pin past what fits, which this rule's
       `overflow: hidden` then clips out of reach. See Gap 13. (Spelled out
       in prose rather than as a declaration because
       test/page-structure.test.mjs greps this rule body for a surviving px
       floor and does not strip comments first.) */
    overflow: hidden;
    display: grid;
    place-items: center;
  }

  html[data-js] .scenes {
    position: relative;
    display: grid;
    place-items: center;
    gap: 0;
  }

  /* One formula, four windows. */
  html[data-js] .scene {
    --rate: 33;
    position: absolute;
    left: 0;
    right: 0;
    width: auto;
    padding: 0 clamp(18px, 4vw, 40px);
    pointer-events: none;
    opacity: max(
      0,
      min(
        calc((var(--p, 0) - var(--in)) * var(--rate)),
        calc((var(--out) - var(--p, 0)) * var(--rate)),
        1
      )
    );
  }

  /* The scene box itself stays transparent to the pointer: all four are
     stacked in the same pin, so a faded-out scene lying on top would otherwise
     swallow every click meant for the visible one.

     Its controls are given back, but only while the scene is live. `inert` is
     the signal scene.js already maintains for the tab order and the
     accessibility tree, so what you can click and what you can reach by
     keyboard are driven by one flag and cannot drift apart. Without this every
     link in scene mode inherits the transparent value and is dead — the
     artifact is a non-interactive mock and does not model this.

     The frame is in the group because the Spotify player scene.js injects is
     none of the other three, and a player nobody can press play inside is
     worse than no player. The label and the input are in it because the music
     act selector is operated by clicking a <label> — leave them out and the
     act list is a picture of a control that only a keyboard can work. Giving
     the controls back is the fix; weakening the scene box itself is not,
     because that is what stops a faded-out scene swallowing the clicks meant
     for the visible one. */
  html[data-js] .scene:not([inert]) a,
  html[data-js] .scene:not([inert]) button,
  html[data-js] .scene:not([inert]) iframe,
  html[data-js] .scene:not([inert]) label,
  html[data-js] .scene:not([inert]) input,
  html[data-js] .scene:not([inert]) [tabindex] {
    pointer-events: auto;
  }

  html[data-js] .scene--hero {
    padding-top: 0;
  }

  /* -- ambient layers -- */

  html[data-js] .stars {
    display: block;
    position: absolute;
    pointer-events: none;
  }

  html[data-js] .stars--near {
    inset: -30% 0;
    transform: translateY(calc(var(--p, 0) * -240px));
    background-image:
      radial-gradient(1.6px 1.6px at 12% 18%, var(--ink), transparent),
      radial-gradient(1.4px 1.4px at 78% 12%, var(--ink-soft), transparent),
      radial-gradient(1.8px 1.8px at 62% 46%, var(--ink-soft), transparent),
      radial-gradient(1.4px 1.4px at 28% 72%, var(--ink-muted), transparent),
      radial-gradient(1.6px 1.6px at 88% 66%, var(--ink-soft), transparent),
      radial-gradient(1.4px 1.4px at 44% 88%, var(--ink-faint), transparent),
      radial-gradient(1.6px 1.6px at 8% 54%, var(--ink-muted), transparent);
  }

  html[data-js] .stars--far {
    inset: -20% 0;
    opacity: 0.55;
    transform: translateY(calc(var(--p, 0) * -90px));
    background-image:
      radial-gradient(1px 1px at 22% 34%, var(--ink-soft), transparent),
      radial-gradient(1px 1px at 54% 22%, var(--ink-muted), transparent),
      radial-gradient(1px 1px at 70% 78%, var(--ink-soft), transparent),
      radial-gradient(1px 1px at 36% 58%, var(--ink-faint), transparent),
      radial-gradient(1px 1px at 92% 40%, var(--ink-muted), transparent),
      radial-gradient(1px 1px at 16% 92%, var(--ink-faint), transparent),
      radial-gradient(1px 1px at 82% 92%, var(--ink-faint), transparent),
      radial-gradient(1px 1px at 48% 6%, var(--ink-faint), transparent);
  }

  html[data-js] .rings {
    display: grid;
    position: absolute;
    inset: 0;
    place-items: center;
    pointer-events: none;
    --c: 0;
    transition: --c 1.2s cubic-bezier(0.2, 0.75, 0.2, 1) 0.28s;
  }

  html[data-js] .stage[data-landed] .rings {
    --c: 1;
  }

  /* One ring system at a time.

     The gate has a ring language of its own — a pulse ring, a dashed spin ring
     and a seal — and the ambient orbit sits behind it on the same centre. The
     two scale from the viewport at different rates, so they read as separate
     systems on a desktop and converge on a phone. Measured at 390 by 844, ring
     3 lands at 237px against the gate's 216px dashed ring: 21px apart, close
     enough to read as one muddled pair, and it crosses the diamond on the way
     past. Ring 2 does the same thing just outside the seal.

     So the ambient rings wait, everywhere, rather than behind a breakpoint. A
     breakpoint would move the collision to the next aspect ratio instead of
     removing it, and the radii it would have to be tuned against drift with
     the viewport. The rings are the backdrop the page arrives on: they fade in
     after entry, which is when there is finally something for them to sit
     behind. The vignette is not a ring and stays where it is. */
  html[data-js] .ring {
    position: absolute;
    aspect-ratio: 1;
    opacity: 0;
    transition: opacity 1s ease 0.3s;
  }

  html[data-js] .stage[data-entered] .ring {
    opacity: 1;
  }

  /* The faintest rung on the ink ladder, not the rule colour the content uses.
     These strokes pass straight through body copy — at --rule they competed
     with the text they crossed and read as part of the layout. Ambient means
     behind: dimmed here so they sit under the copy rather than across it. */
  html[data-js] .ring__path {
    position: absolute;
    inset: 0;
    border: var(--rule-width) solid var(--ink-veil);
    border-radius: 50%;
  }

  html[data-js] .ring__path--dashed {
    border-style: dashed;
  }

  html[data-js] .ring__marker {
    position: absolute;
    top: -2px;
    left: 50%;
    width: 5px;
    height: 5px;
    margin-left: -2.5px;
    border-radius: 50%;
    /* Follows its stroke down the ladder. Left at --ink-muted it would have
       become the brightest ambient thing on the page once the rings dimmed. */
    background: var(--ink-dim);
  }

  html[data-js] .ring__marker--accent {
    top: -4px;
    width: 9px;
    height: 9px;
    margin-left: -4.5px;
    background: var(--accent);
    box-shadow: 0 0 12px var(--accent-glow);
  }

  html[data-js] .ring__marker--low {
    top: auto;
    bottom: -3px;
    width: 6px;
    height: 6px;
    margin-left: -3px;
  }

  html[data-js] .ring--1 {
    width: min(150vw, 140vh, 1300px);
    transform: scale(calc((0.92 + var(--p, 0) * 0.3) * (1 + var(--c, 0) * 0.08)));
  }

  html[data-js] .ring--1 .ring__path {
    border-color: var(--ink-veil);
    transform: rotate(calc(var(--p, 0) * 300deg));
  }

  html[data-js] .ring--2 {
    width: min(104vw, 96vh, 960px);
    transform: scale(calc((0.9 + var(--p, 0) * 0.26) * (1 + var(--c, 0) * 0.14)));
  }

  html[data-js] .ring--2 .ring__path {
    transform: rotate(calc(var(--p, 0) * -220deg));
  }

  html[data-js] .ring--3 {
    width: min(74vw, 70vh, 700px);
    transform: scale(calc((0.82 + var(--p, 0) * 0.5) * (1 + var(--c, 0) * 0.2)));
  }

  /* This lit leading edge was the bright arc cutting across the copy: at
     --ink-soft it was drawn at nearly full ink and was by far the loudest
     thing in the music and code scenes. The composition wants ONE ring with a
     lit edge, so the edge survives — it is just taken far enough down the
     ladder to sit below the scene rule, which is what makes it backdrop
     rather than foreground. It stays the brightest ring, by one rung. */
  html[data-js] .ring--3 .ring__path {
    border-top-color: var(--rule-soft);
    transform: rotate(calc(var(--p, 0) * 400deg));
  }

  html[data-js] .ring--4 {
    width: min(112vw, 104vh, 1060px);
    transform: scaleY(calc(0.2 + var(--p, 0) * 0.16)) rotate(calc(var(--p, 0) * -150deg)) scale(calc(1 + var(--c, 0) * 0.16));
  }

  html[data-js] .rings__vignette {
    position: absolute;
    inset: 0;
    background: radial-gradient(circle at 50% 46%, transparent 28%, var(--canvas-veil) 70%, var(--canvas-deep) 100%);
  }

  /* Fixed, not absolute: the sweep is a light passing over the viewport, so it
     must not ride the pinned stage's own offset. */
  html[data-js] .beam {
    display: block;
    position: fixed;
    left: 0;
    right: 0;
    top: 50%;
    height: 40%;
    margin-top: -20%;
    pointer-events: none;
    opacity: 0;
    background: linear-gradient(to bottom, transparent, var(--accent-beam) 45%, var(--rule-soft) 55%, transparent);
  }

  /* -- the gate becomes a full-bleed overlay rather than a block -- */

  html[data-js] .gate {
    display: grid;
    position: fixed;
    inset: 0;
    z-index: 5;
    opacity: max(0, min(calc((0.15 - var(--p, 0)) * 33), 1));
    transition: opacity 0.55s ease, transform 1s cubic-bezier(0.2, 0.7, 0.2, 1), filter 0.8s ease;
  }

  html[data-js] .stage:not([data-entered]) .scene--hero {
    opacity: 0;
  }

  html[data-js] .stage[data-entered] .gate {
    opacity: 0;
    transform: scale(1.45);
    filter: blur(9px);
    pointer-events: none;
  }

  /* The monogram leaves towards the viewer as the gate blurs out. */
  html[data-js] .stage[data-entered] .gate__logo {
    transform: scale(2.1);
    opacity: 0;
  }

  /* Scene mode is the one place the mark starts hidden, because there the gate
     is still shut and the corner belongs to nothing yet. Entering fades it in,
     on the timing the base rule carries.

     A fade, on opacity alone. This reveal was briefly a clip wipe travelling
     with the sweep; it is a 22px mark in a corner, and the wipe cost a
     keyframe block and a second property to say something the fade already
     says at that size. What mattered was never the wipe, it was the moment —
     the mark used to arrive half a second after the sweep's light had left its
     corner, which read as the page assembling after the animation instead of
     during it.

     Opacity only, so nothing here meets the two properties the entry
     choreography already drives: transform, which carries the gate's own copy
     of this monogram out to 2.1 as the gate recedes, and filter, which is
     logoGlow's. Those run on the gate monogram; this runs on the corner mark;
     they share no timeline. */
  html[data-js] .mark {
    opacity: 0;
  }

  html[data-js] .stage[data-entered] ~ .mark {
    opacity: 0.92;
  }

  html[data-js] .stage[data-entered] .gate__diamond {
    transform: rotate(135deg) scale(1.5);
    opacity: 0;
  }

  html[data-js] .stage[data-entered] .beam {
    animation: scanSweep 1s ease-out both;
  }

  /* The hero arrives in the sweep's wake, not ahead of it.
     scanSweep runs 1s ease-out, so its light clears the middle of the viewport
     around 0.43s — measured. At the old 0.18s the hero was already resolving
     while the print head was still above it, which read as the page assembling
     itself rather than being printed downward. 0.45s puts it just behind the
     light, the same relationship the corner mark now has at 0.15s for a corner
     the sweep clears at ~0.1s. */
  html[data-js] .stage[data-entered] .scene--hero > * {
    animation: projectIn 0.9s 0.45s cubic-bezier(0.2, 0.7, 0.2, 1) both;
  }

  /* -- finale choreography -- */

  html[data-js] .smoke,
  html[data-js] .burst,
  html[data-js] .steam {
    display: block;
  }

  html[data-js] .smoke {
    position: absolute;
    left: 50%;
    bottom: -8%;
    width: min(88vw, 760px);
    height: min(34vh, 300px);
    transform: translateX(-50%);
    pointer-events: none;
  }

  html[data-js] .smoke__puff {
    position: absolute;
    aspect-ratio: 1;
    border-radius: 50%;
    background: var(--rule-soft);
  }

  html[data-js] .smoke__puff--a {
    left: 8%;
    bottom: 0;
    width: 46%;
    filter: blur(46px);
    animation: smokeA 17s ease-in-out infinite alternate;
  }

  html[data-js] .smoke__puff--b {
    right: 6%;
    bottom: 4%;
    width: 40%;
    filter: blur(50px);
    animation: smokeB 23s ease-in-out infinite alternate;
  }

  html[data-js] .smoke__puff--c {
    left: 28%;
    bottom: -12%;
    width: 52%;
    filter: blur(58px);
    animation: smokeC 29s ease-in-out infinite alternate;
  }

  html[data-js] .burst {
    position: absolute;
    left: 50%;
    bottom: 6%;
    width: 0;
    height: 0;
    pointer-events: none;
    z-index: 2;
  }

  html[data-js] .burst__flash {
    position: absolute;
    left: -150px;
    top: -150px;
    width: 300px;
    height: 300px;
    border-radius: 50%;
    background: radial-gradient(circle, var(--rule-strong), var(--rule-soft) 45%, transparent 70%);
  }

  html[data-js] .burst__shard {
    position: absolute;
    left: -3px;
    top: -3px;
    width: var(--s);
    height: var(--s);
    border-radius: 50%;
    background: var(--ink);
  }

  html[data-js] .burst__shard--accent {
    background: var(--accent);
  }

  html[data-js] .steam {
    position: absolute;
    left: 64%;
    top: 62.5%;
    width: 0;
    height: 0;
    pointer-events: none;
  }

  /* Three weights of puff, each with its own rise. Geometry and timing come
     from the per-puff custom properties in the markup. */
  html[data-js] .steam__puff {
    position: absolute;
    left: var(--x);
    bottom: var(--y);
    width: var(--w);
    height: var(--h);
    border-radius: 50%;
    animation-duration: var(--dur);
    animation-delay: var(--delay);
    animation-timing-function: ease-out;
    animation-iteration-count: infinite;
  }

  html[data-js] .steam__puff--a {
    background: var(--steam-1);
    filter: blur(4px) drop-shadow(0 0 9px var(--steam-glow));
    animation-name: steam1;
  }

  html[data-js] .steam__puff--b {
    background: var(--steam-2);
    filter: blur(5px) drop-shadow(0 0 9px var(--steam-glow));
    animation-name: steam2;
  }

  html[data-js] .steam__puff--c {
    background: var(--steam-3);
    filter: blur(6px) drop-shadow(0 0 9px var(--steam-glow));
    animation-name: steam3;
  }

  html[data-js] .scene--finale:not([data-fire]) [data-reveal],
  html[data-js] .scene--finale:not([data-fire]) .burst {
    opacity: 0;
  }

  html[data-js] .scene--finale[data-fire] .burst__flash {
    animation: burstFlash 0.7s ease-out both;
  }

  html[data-js] .scene--finale[data-fire] .burst__shard {
    animation: shard 1.15s var(--d) cubic-bezier(0.12, 0.8, 0.25, 1) both;
  }

  html[data-js] .scene--finale[data-fire] [data-reveal] {
    animation: revealUp 0.75s 0.4s cubic-bezier(0.2, 0.7, 0.2, 1) both;
  }
}

/* ---------------------------------------------------------- choreography -- */

@property --c {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@keyframes gatePulse {
  0% { transform: scale(0.97); opacity: 0.65; }
  100% { transform: scale(1.03); opacity: 1; }
}

@keyframes gateSpin {
  to { transform: rotate(360deg); }
}

@keyframes scanSweep {
  0% { opacity: 0; transform: translateY(-130%); }
  22% { opacity: 0.95; }
  100% { opacity: 0; transform: translateY(130%); }
}

@keyframes projectIn {
  0% { opacity: 0; transform: perspective(900px) rotateX(16deg) scale(0.9); filter: blur(12px); }
  55% { opacity: 1; }
  100% { opacity: 1; transform: none; filter: blur(0); }
}

@keyframes burstFlash {
  0% { opacity: 0; transform: scale(0.4); }
  9% { opacity: 0.9; }
  100% { opacity: 0; transform: scale(1.6); }
}

@keyframes shard {
  0% { transform: translate(0, 0) scale(0.3); opacity: 0; }
  10% { opacity: 1; }
  100% { transform: translate(var(--x, 0), var(--y, 0)) scale(1); opacity: 0; }
}

@keyframes revealUp {
  0% { opacity: 0; transform: translateY(18px); filter: blur(8px); }
  100% { opacity: 1; transform: translateY(0); filter: blur(0); }
}

@keyframes smokeA {
  0% { transform: translate(-8%, 4%) scale(1); }
  100% { transform: translate(10%, -14%) scale(1.35); }
}

@keyframes smokeB {
  0% { transform: translate(6%, 6%) scale(1.1); }
  100% { transform: translate(-10%, -10%) scale(1.4); }
}

@keyframes smokeC {
  0% { transform: translate(0, 8%) scale(0.9); }
  100% { transform: translate(4%, -18%) scale(1.3); }
}

/* Three rises rather than one parameterised curve: each puff drifts on its own
   path, which is what keeps six of them from reading as a repeating loop. */
@keyframes steam1 {
  0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
  14% { opacity: 0.9; }
  45% { transform: translate(-9px, -24px) scale(0.95); }
  72% { opacity: 0.5; }
  100% { opacity: 0; transform: translate(6px, -62px) scale(1.7); }
}

@keyframes steam2 {
  0% { opacity: 0; transform: translate(0, 0) scale(0.35); }
  16% { opacity: 0.85; }
  40% { transform: translate(7px, -19px) scale(0.9); }
  70% { transform: translate(-4px, -38px) scale(1.2); opacity: 0.45; }
  100% { opacity: 0; transform: translate(11px, -70px) scale(1.8); }
}

@keyframes steam3 {
  0% { opacity: 0; transform: translate(0, 0) scale(0.5); }
  12% { opacity: 0.95; }
  50% { transform: translate(-3px, -30px) scale(1.15); }
  100% { opacity: 0; transform: translate(-12px, -58px) scale(1.55); }
}

/* The gate's three soft layers. Two slow contrary drifts and a breath. */
@keyframes nebulaA {
  0% { transform: translate(-6%, 4%) scale(1) rotate(0); }
  100% { transform: translate(8%, -6%) scale(1.28) rotate(40deg); }
}

@keyframes nebulaB {
  0% { transform: translate(5%, -3%) scale(1.15) rotate(0); }
  100% { transform: translate(-7%, 7%) scale(1.4) rotate(-52deg); }
}

@keyframes glowPulse {
  0% { opacity: 0.5; transform: scale(0.94); }
  100% { opacity: 0.9; transform: scale(1.08); }
}

/* The monogram breathes. Both stops use the same two tokens as the at-rest
   filter — only the blur radii move, so the mark never changes hue and never
   moves. Slower than the pulse ring (4.5s) and faster than the bloom (6s), so
   the three gate rhythms stay audibly separate rather than beating together. */
@keyframes logoGlow {
  from { filter: drop-shadow(0 0 7px var(--ink-glow)) drop-shadow(0 0 24px var(--accent-aura)); }
  to { filter: drop-shadow(0 0 15px var(--ink-glow)) drop-shadow(0 0 46px var(--accent-aura)); }
}

/* =========================================================================
   REDUCED MOTION — removed, not damped (DESIGN.md §9).
   Scene mode is already excluded by its own media query; this strips the
   ambient animation that would otherwise survive in static mode.
   ========================================================================= */

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

  .gate__pulse,
  .gate__spin,
  .gate__nebula,
  .gate__bloom,
  .gate__logo {
    animation: none !important;
  }
}
