/* Observatory in Motion — the motion layer (R1).
   Spec: docs/superpowers/specs/2026-09-27-observatory-in-motion-design.md
   Loaded last, after observatory.css. Every hidden starting state in this file is
   scoped under html.motion-live, which motion.js adds only when it runs and motion
   is allowed. Without it, every page renders in its finished state. */

/* ---------- reveal (spec §4.3, G1, G3) ---------- */
@keyframes mo-rise {
  from { opacity: 0; transform: translateY(10px); }
}
html.motion-live :is([data-in="head"], [data-in="row"], [data-in="card"]):not(.in) > :not(.mo-deco) {
  opacity: 0;
}
/* backwards fill: holds the start state during the delay, then releases, so the
   existing hover transforms on these children keep working after the reveal. */
html.motion-live :is([data-in="head"], [data-in="row"], [data-in="card"]).in:not(.is-instant) > :not(.mo-deco) {
  animation: mo-rise var(--t-block) var(--ease-out) backwards;
  animation-delay: calc(var(--mo-i, 0) * var(--stagger-row) + var(--mo-step, 0ms));
}
html.motion-live [data-in="head"] > :nth-child(2) { --mo-step: 60ms; }
html.motion-live [data-in="head"] > :nth-child(3) { --mo-step: 120ms; }

@media print {
  /* Print is content: finished text, no decorations, no half-rolled digits. */
  .mo-deco { display: none !important; }
  html.motion-live [data-in] > :not(.mo-deco) { opacity: 1 !important; animation: none !important; }
  .mo-odo-col { transform: translateY(calc(var(--n, 0) * -1em)) !important; transition: none !important; }
}

/* ---------- decorations: rule sweep, registration marks, odometers, thumbnail scan, trace ---------- */
@property --mo-k { syntax: "<length>"; inherits: false; initial-value: 16px; }
.mo-anchor { position: relative; }
.mo-deco { position: absolute; pointer-events: none; }

/* A copper sweep runs along the target's existing hairline, then fades: the line
   "registers" without doubling the rule that is already there. */
.mo-rule { left: 0; right: 0; height: 1px; background: var(--verify); opacity: 0; transform-origin: left center; }
.mo-rule-bottom { bottom: 0; } /* inside the padding box, so rows with overflow:hidden still show it */
.mo-rule-top { top: 0; }
@keyframes mo-sweep {
  0% { opacity: 1; transform: scaleX(0); }
  70% { opacity: 1; transform: scaleX(1); }
  100% { opacity: 0; transform: scaleX(1); }
}
html.motion-live [data-in].in:not(.is-instant) > .mo-rule {
  animation: mo-sweep var(--t-draw) var(--ease-wipe) both;
  animation-delay: calc(var(--mo-i, 0) * var(--stagger-row));
}

.mo-reg,
.article-body > .mo-frame {
  inset: 0;
  --mo-c: var(--verify);
  background:
    linear-gradient(var(--mo-c), var(--mo-c)) 0 0 / var(--mo-k) 1px no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 0 0 / 1px var(--mo-k) no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 100% 0 / var(--mo-k) 1px no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 100% 0 / 1px var(--mo-k) no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 0 100% / var(--mo-k) 1px no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 0 100% / 1px var(--mo-k) no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 100% 100% / var(--mo-k) 1px no-repeat,
    linear-gradient(var(--mo-c), var(--mo-c)) 100% 100% / 1px var(--mo-k) no-repeat;
  transition: --mo-k var(--t-hover) var(--ease-out);
}
html.motion-live :is([data-in]:not(.in) > .mo-reg, .mo-frame:not(.in)) { --mo-k: 0px; }
html.motion-live :is([data-in].in:not(.is-instant) > .mo-reg, .mo-frame.in:not(.is-instant)) {
  transition-duration: var(--t-draw);
  transition-delay: calc(var(--mo-i, 0) * var(--stagger-card) + 150ms);
}
[data-in].in:is(:hover, :focus-within) > .mo-reg,
html.motion-live [data-in].in:not(.is-instant):is(:hover, :focus-within) > .mo-reg {
  --mo-k: 30px;
  transition-delay: 0s;
  transition-duration: var(--t-hover);
}

.mo-odo { display: inline-block; height: 1em; overflow: hidden; line-height: 1; vertical-align: -0.2em; }
/* The column exists only while it rolls (motion.js): it starts at zero and rolls to its value. */
.mo-odo-col { display: block; transform: translateY(0); transition: transform var(--t-count) var(--ease-out) 0.15s; }
.mo-odo-col.is-rolled { transform: translateY(calc(var(--n, 0) * -1em)); }
.mo-odo-col > span { display: block; height: 1em; line-height: 1; }

@keyframes mo-scan { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes mo-settle { from { transform: scale(1.25); } }
html.motion-live [data-in="row"].in:not(.is-instant) > :is(.obs-thumb, .obs-next-art) {
  animation: mo-scan var(--t-scene) var(--ease-wipe) backwards;
  animation-delay: calc(var(--mo-i, 0) * var(--stagger-row));
}
html.motion-live [data-in="row"].in:not(.is-instant) > :is(.obs-thumb, .obs-next-art) img {
  animation: mo-settle var(--t-scene) var(--ease-out) backwards;
  animation-delay: calc(var(--mo-i, 0) * var(--stagger-row));
}

.mo-trace { left: 0; right: 0; top: 0; width: 100%; height: 36px; overflow: visible; fill: none; stroke: var(--obs-copper, var(--verify)); stroke-width: 1.2; opacity: 0.45; }
.mo-trace path { stroke-dasharray: 1; stroke-dashoffset: 0; }
html.motion-live [data-in]:not(.in) > .mo-trace path { stroke-dashoffset: 1; }
html.motion-live [data-in].in:not(.is-instant) > .mo-trace path { transition: stroke-dashoffset var(--t-intro) var(--ease-wipe) 0.2s; }

/* ---------- counters ---------- */
/* Counters (motion.js): while counting, the real digits stay in the page text (copied,
   found, crawled and spoken as they are) but are painted transparent; the count is drawn
   over them as generated content with empty alternative text. The real digits hold the
   final width, so nothing after them moves (G5); contextual span rules such as
   `.dives-more p span { margin-right }` must not reach the wrapper. */
.mo-num { position: relative; margin: 0 !important; padding: 0 !important; -webkit-text-fill-color: transparent; }
.mo-num::after {
  content: attr(data-shown);
  content: attr(data-shown) / "";
  position: absolute;
  inset: 0 0 0 auto; /* anchored to the digits' end: a wider count hangs before them, never over the words after */
  text-align: end;
  font-variant-numeric: tabular-nums;
  -webkit-text-fill-color: currentcolor;
}
@media print {
  /* A count in progress prints its real number, not the digits drawn so far. */
  .mo-num { -webkit-text-fill-color: currentcolor !important; }
  .mo-num::after { content: none !important; }
}

/* ---------- smart header (site-nav.js adds .is-smart; spec §6) ---------- */
/* register.css locks .site-nav { transform: none !important } in two layers, so the
   header moves with the individual `translate` property instead of `transform`. */
body.observatory .site-nav.is-smart {
  position: sticky;
  top: 0;
  z-index: 150;
  transition: translate var(--t-element) var(--ease-out), background-color var(--t-hover) ease;
}
body.observatory[data-observatory-kind="home"] .site-nav.is-smart { position: fixed; }
body.observatory[data-observatory-kind="home"] .site-nav.is-smart.is-scrolled { background: var(--obs-forest); }
body.observatory .site-nav.is-smart.is-hidden { translate: 0 -100%; }
body.observatory .site-nav .nav-progress {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: var(--obs-copper); transform: scaleX(0); transform-origin: left center;
  opacity: 0; transition: opacity var(--t-hover) ease; pointer-events: none;
}
body.observatory .site-nav.is-scrolled .nav-progress { opacity: 1; }
html.nav-smart .scroll-progress { display: none; }

/* ---------- page to page (spec §4.1, moment 3) ---------- */
body.observatory .obs-cover img { view-transition-name: obs-cover; }
::view-transition-group(obs-cover),
::view-transition-group(register-entry-title) {
  animation-duration: var(--t-page);
  animation-timing-function: var(--ease-cine);
}
::view-transition-group(obs-cover) { overflow: clip; }
::view-transition-old(obs-cover),
::view-transition-new(obs-cover) { height: 100%; object-fit: cover; }

/* ---------- search palette (spec §6) ---------- */
@keyframes mo-palette-in { from { opacity: 0; transform: translateY(-6px) scale(0.985); } }
@keyframes mo-item-in { from { opacity: 0; transform: translateY(6px); } }
html:not([data-motion="reduced"]) .palette-backdrop:not([hidden]) .palette {
  animation: mo-palette-in var(--t-hover) var(--ease-out) backwards;
}
html:not([data-motion="reduced"]) .palette-results.is-fresh .palette-item {
  animation: mo-item-in var(--t-element) var(--ease-out) backwards;
  animation-delay: calc(var(--mo-i, 0) * 30ms);
}
/* The selection marker: 1 px tall at the list's top, moved and stretched to the selected row by
   site-search.js (translate and scale only), on the reading-start edge in either direction. */
.palette-results { position: relative; }
.palette-marker { position: absolute; top: 0; inset-inline-start: 0; width: 2px; height: 1px; background: var(--verify); transform-origin: 0 0; pointer-events: none; }
html:not([data-motion="reduced"]) .palette-marker { transition: translate var(--t-element) var(--ease-out), scale var(--t-element) var(--ease-out); }

/* ---------- right-to-left: every direction mirrors (spec §6) ---------- */
html[dir="rtl"] .mo-rule { transform-origin: right center; }
html[dir="rtl"] body.observatory .site-nav .nav-progress { transform-origin: right center; }

/* ==================== R2: the home (spec §5.1) ====================
   Every R2 rule below is screen-only: in print each home block shows the finished state that
   observatory.css defines. */

/* ---------- the home opening (spec §4.1 cinematic moment 1; G1, G2, G4, G5) ----------
   motion-gate.js marks <html> before first paint: motion-pending when motion is allowed, plus
   intro on the first home visit of a session. Every start state below exists only inside a
   finite animation with backwards fill, so the page finishes by itself even if no script runs
   again; the gate (3.2 s after the first frame) and motion.js then drop the classes.
   The hero image only zooms and darkens, so it paints on the first frame. It is not the LCP
   element, though: Chrome ignores an image that covers the whole viewport, so the page's LCP
   element is its largest text. The sub-line therefore stays on screen from the first frame and
   later visits settle without a fade, so the opening never delays LCP (G4).
   observatory.css locks `transform` on the hero image and the title lines, so the opening moves
   them with the individual `scale` and `translate` properties; masks are clip-paths, which never
   touch layout (G5). register.css switches every hero-copy animation off with !important, so
   the opening's own declarations carry !important too (and win on specificity). */
@keyframes mo-open-art { from { scale: 1.16; filter: brightness(0.28) saturate(0.6); } }
@keyframes mo-open-line { from { translate: 0 118%; } }
@keyframes mo-open-fade { from { opacity: 0; translate: 0 14px; } }
@keyframes mo-open-draw { from { transform: scaleX(0); } }
@keyframes mo-settle-art { from { scale: 1.04; } }
@keyframes mo-settle-rise { from { translate: 0 8px; } }
@keyframes mo-live { from { transform: scale(1); opacity: 0.55; } to { transform: scale(2.8); opacity: 0; } }
@media screen {
  html.motion-pending.intro body[data-observatory-kind="home"] .obs-home-art img { animation: mo-open-art var(--t-intro) var(--ease-out) backwards; }
  html.motion-pending.intro body[data-observatory-kind="home"] :is(.obs-line, .hero-title .title-line) { clip-path: inset(-0.3em -0.2em -0.1em -0.2em); }
  html.motion-pending.intro body[data-observatory-kind="home"] :is(.obs-line > span, .hero-title .title-ink) {
    animation: mo-open-line var(--t-draw) var(--ease-out) var(--mo-d, 0ms) backwards !important;
  }
  html.motion-pending.intro body[data-observatory-kind="home"] .obs-line:nth-child(1) > span { --mo-d: 250ms; }
  html.motion-pending.intro body[data-observatory-kind="home"] .obs-line:nth-child(2) > span { --mo-d: calc(250ms + var(--stagger-row)); }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-title .title-line:nth-child(1) .title-ink { --mo-d: 750ms; }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-title .title-line:nth-child(2) .title-ink { --mo-d: calc(750ms + var(--stagger-row)); }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-title .title-line:nth-child(3) .title-ink { --mo-d: calc(750ms + 2 * var(--stagger-row)); }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-title::after { animation: mo-open-draw var(--t-draw) var(--ease-wipe) 1.25s backwards; }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-eyebrow { animation: mo-open-fade var(--t-hero) var(--ease-out) 200ms backwards !important; }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-actions { animation: mo-open-fade var(--t-hero) var(--ease-out) 1.65s backwards !important; }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-note { animation: mo-open-fade var(--t-hero) var(--ease-out) var(--mo-note, 1.3s) backwards !important; }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-note .obs-chain-link::before { animation: mo-open-draw var(--t-element) var(--ease-wipe) calc(var(--mo-note, 1.3s) + 0.6s) backwards; }
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-note .obs-chain-link ~ .obs-chain-link::before { animation-delay: calc(var(--mo-note, 1.3s) + 0.9s); }
  /* Later visits in the same session: a settle of at most 0.5 s that moves but never fades. */
  html.motion-pending:not(.intro) body[data-observatory-kind="home"] .obs-home-art img { animation: mo-settle-art var(--t-element) var(--ease-out) backwards; }
  html.motion-pending:not(.intro) body[data-observatory-kind="home"] :is(.hero-copy > *, .hero-note) { animation: mo-settle-rise var(--t-element) var(--ease-out) backwards !important; }
  html.motion-pending:not(.intro) body[data-observatory-kind="home"] .hero-copy > :nth-child(n + 3) { animation-delay: 60ms !important; }
  html.motion-pending:not(.intro) body[data-observatory-kind="home"] :is(.hero-copy > :nth-child(n + 5), .hero-note) { animation-delay: 120ms !important; }
  /* The eyebrow's live dot pulses while the hero is in view (G6: it stops once covered). */
  html.motion-live body[data-observatory-kind="home"] .hero-eyebrow::after {
    content: "";
    position: absolute;
    inset-inline-start: 0;
    top: calc(50% - 3px);
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--obs-copper);
    background: color-mix(in srgb, var(--live) 55%, var(--obs-light-ink));
    animation: mo-live var(--t-loop) var(--ease-out) infinite;
    pointer-events: none;
  }
}
@media screen and (max-width: 1050px) {
  html.motion-pending.intro body[data-observatory-kind="home"] .hero-note { --mo-note: 1.8s; }
}

/* ---------- the curtain: the home hero holds while 01 slides over it (spec §5.1 items 1–2) ----------
   motion.js stamps data-scene="curtain" on the hero inside .obs-curtain and writes --p (0 → 1 as
   the hero is covered) and --mo-stick (a hero taller than the viewport scrolls until its bottom
   meets the viewport's bottom, then holds). The wrapper ends after 01, so the hero then leaves
   the viewport and its recorder canvas parks. Once covered, the hero is wholly above the viewport,
   so it lets go of its hold: it is never laid out under 01, where audits would read it over 01.
   Reduced motion: an ordinary scrolling hero. */
@media screen {
  html.motion-live [data-scene="curtain"]:not(.is-covered) { position: sticky; top: var(--mo-stick, 0px); }
  html.motion-live [data-scene="curtain"] .obs-home-art { transform: scale(calc(1 + var(--p, 0) * 0.08)); transform-origin: 62% 45%; }
  html.motion-live [data-scene="curtain"].is-active .obs-home-art { will-change: transform; }
  html.motion-live [data-scene="curtain"] .hero-scrim::after { content: ""; position: absolute; inset: 0; background: var(--obs-forest); opacity: calc(var(--p, 0) * 0.72); }
  html.motion-live [data-scene="curtain"].is-covered .hero-eyebrow::after { animation: none; opacity: 0; }
  html.motion-live .obs-curtain > .practice {
    position: relative;
    z-index: 1;
    background: var(--bg-0);
    box-shadow: 0 -40px 80px -40px color-mix(in srgb, var(--obs-forest) 60%, transparent);
  }
}

/* ---------- letterbox scenes: the home's feature and 04's image (spec §4.1 moment 2, §5.1) ----------
   motion.js writes --c on each scene: 1 far from the viewport's centre, 0 at it. The feature clips
   as a whole, as on the approved board: its words sit inside the art's safe area, clear of the widest
   inset, and nothing of it (the words' shade included) paints outside the frame. It is a scene, not a
   reveal target, so no IntersectionObserver reads its clip (G6). 04 clips only its image frame, so
   its caption stays whole. */
@media screen {
  html.motion-live :is(.obs-feature[data-scene="letterbox"], [data-scene="letterbox"] .obs-frame) { clip-path: inset(calc(var(--c, 0) * 12%) calc(var(--c, 0) * 8%)); }
  html.motion-live [data-scene="letterbox"] :is(.obs-feature-art, .obs-frame) img { transform: scale(calc(1 + var(--c, 0) * 0.2)); }
  html.motion-live [data-scene="letterbox"].is-active :is(.obs-feature-art, .obs-frame) img { will-change: transform; }
  /* Keyboard focus opens the feature at once, so its inset focus ring is never clipped (G7). */
  html.motion-live .obs-feature[data-scene="letterbox"]:focus-visible { clip-path: none; }
}

/* ---------- the field-notes timeline (spec §5.1 item 4) ----------
   motion.js writes --t on #timeline (the rail's fill: it reaches 62% of the viewport's height)
   and .is-lit on every note whose tick the fill has passed; the note's chain links then draw.
   Finished state (no engine, reduced motion, print): a full rail, every tick lit, links drawn. */
@media screen {
  html.motion-live [data-scene="timeline"]::after { transform: scaleY(var(--t, 1)); }
  html.motion-live [data-scene="timeline"] > .entry::before { transition: background-color var(--t-element) var(--ease-out), border-color var(--t-element) var(--ease-out), transform var(--t-element) var(--ease-out); }
  html.motion-live [data-scene="timeline"] > .entry:not(.is-lit)::before { background-color: var(--bg-0); border-color: var(--ink-3); }
  html.motion-live [data-scene="timeline"] > .entry.is-lit::before { transform: scale(1.15); }
  html.motion-live [data-scene="timeline"] > .entry .obs-chain-link::before { transition: transform var(--t-hero) var(--ease-wipe) 0.3s; }
  html.motion-live [data-scene="timeline"] > .entry .obs-chain-link ~ .obs-chain-link::before { transition-delay: 0.9s; }
  html.motion-live [data-scene="timeline"] > .entry:not(.is-lit) .obs-chain-link::before { transform: scaleX(0); transition-delay: 0s; }
}

/* ---------- the ten-day ruler fills move by move (spec §5.1 item 7) ----------
   The ruler is a reveal target (kind "deco": its words never hide); each move's bar fills over
   0.3 s per day, starting where the previous move ended. */
@keyframes mo-fill { from { transform: scaleX(0); } }
@keyframes mo-dim { from { opacity: 0.25; } }
@media screen {
  html.motion-live .obs-ruler[data-in]:not(.in) i::after { transform: scaleX(0); }
  html.motion-live .obs-ruler[data-in]:not(.in) :is(b, strong) { opacity: 0.25; }
  html.motion-live .obs-ruler[data-in].in:not(.is-instant) i::after { animation: mo-fill calc(var(--w) * 0.3s) linear calc(var(--at) * 0.3s + 0.3s) backwards; }
  html.motion-live .obs-ruler[data-in].in:not(.is-instant) :is(b, strong) { animation: mo-dim var(--t-element) ease calc(var(--at) * 0.3s + 0.3s) backwards; }
}

/* ==================== R3: articles and the library (spec §5.2, §5.3) ====================
   Every R3 rule below is screen-only: in print each block shows its finished state. */

/* ---------- article and page openings (spec §4.1 moment 2; G1, G2, G4, G5) ----------
   motion-gate.js marks <html> motion-pending before first paint on the articles and the library
   (never intro, which is the home's). Each start state lives only inside a finite animation with
   backwards fill, so the page finishes by itself; the gate drops the class 3.2 s after the first
   frame and motion.js once the opening is over.
   - The cover or page art paints on the first frame (G4): its image opens out of a letterbox and
     settles from 1.2x, with clip and scale only. The clip is compensated for the zoom, so the
     window starts at 14% / 10% of the frame.
   - The title rises through a mask on wide screens. On phones, where it is the largest text on the
     first screen, it only rises 12 px and stays painted (G4).
   - The meta line fades in, and its section count counts up as it shows (motion.js).
   register.css switches every article-hero and page-hero child's animation off with !important, so
   the title and meta declarations carry !important too (and win on specificity). */
@keyframes mo-letterbox { from { clip-path: inset(20% 16.7%); } to { clip-path: inset(0); } }
@keyframes mo-cover-settle { from { scale: 1.2; } }
@keyframes mo-title-mask { from { translate: 0 110%; clip-path: inset(-0.3em -0.2em 110% -0.2em); } to { clip-path: inset(-0.3em -0.2em -0.3em -0.2em); } }
@keyframes mo-title-rise { from { translate: 0 12px; } }
@media screen {
  html.motion-pending body:is([data-observatory-kind="article"], [data-observatory-kind="library"]) :is(.obs-cover, .obs-page-art) img {
    animation: mo-letterbox var(--t-scene) var(--ease-wipe) 0.5s backwards, mo-cover-settle 2.2s var(--ease-out) 0.5s backwards;
  }
  html.motion-pending body:is([data-observatory-kind="article"], [data-observatory-kind="library"]) :is(.article-hero, .page-hero-copy) > h1 {
    animation: mo-title-mask var(--t-draw) var(--ease-out) 0.25s backwards !important;
  }
  html.motion-pending body[data-observatory-kind="article"] :is(.article-hero, .page-hero-copy) > .article-meta {
    animation: mo-open-fade var(--t-hero) var(--ease-out) 1s backwards !important;
  }
}
@media screen and (max-width: 700px) {
  html.motion-pending body:is([data-observatory-kind="article"], [data-observatory-kind="library"]) :is(.article-hero, .page-hero-copy) > h1 { animation-name: mo-title-rise !important; }
}

/* ---------- the cover scene: the image drifts with the scroll (spec §4.1 moment 2, §5.2) ----------
   motion.js writes --y on the cover or page art (0 entering at the bottom, 1 leaving at the top).
   The image holds a 1.06 zoom, and drifts by at most the 3% of its height that the zoom leaves on
   each side, so no edge ever shows (about ±15 px on a wide cover). The opening settles to this zoom;
   if the engine starts after the opening, the zoom eases in instead of jumping. */
@media screen {
  html.motion-live [data-scene="cover"] img { scale: 1.06; translate: 0 calc((var(--y, 0.5) - 0.5) * 6%); transition: scale var(--t-element) var(--ease-out); }
  html.motion-live [data-scene="cover"].is-active img { will-change: translate; }
}

/* ---------- the reading rail, 1100 px and wider (spec §4.3 data-rail, §5.2) ----------
   motion.js builds it on an article and writes --rp (reading progress), each tick's place along the
   article (--y) and .is-on while the reader is inside the article. It sits in the left margin, left of
   the wide figures where the margin allows. Keyboard focus shows it at once. Hidden in print. */
.mo-rail { display: none; }
@media screen and (min-width: 1100px) {
  .mo-rail { display: block; position: fixed; z-index: 60; inset-inline-start: clamp(18px, calc(50vw - 564px), 70px); top: 120px; bottom: 132px; width: 1px; background: var(--line-strong); opacity: 0; pointer-events: none; transition: opacity var(--t-block) ease; }
  .mo-rail.is-on, .mo-rail:focus-within { opacity: 1; pointer-events: auto; }
  .mo-rail-fill { position: absolute; inset: 0; background: var(--verify); transform: scaleY(var(--rp, 0)); transform-origin: top center; }
  .mo-rail-tick { position: absolute; inset-inline-start: -12px; top: calc(var(--y, 0%) - 12px); width: 24px; height: 24px; padding: 0; border: 0; background: none; cursor: pointer; }
  .mo-rail-tick::before { content: ""; position: absolute; inset: 7px; box-sizing: border-box; border: 1px solid var(--ink-3); border-radius: 50%; background: var(--bg-0); transition: background-color var(--t-hover) ease, border-color var(--t-hover) ease; }
  .mo-rail-tick.is-on::before { border-color: var(--verify); background: var(--verify); }
  .mo-rail-tick::after { content: attr(data-n) / ""; position: absolute; inset-inline-start: 26px; top: 6px; font: 600 var(--fs-micro)/1 var(--font-sans); letter-spacing: 0.08em; white-space: nowrap; color: var(--ink-3); }
  .mo-rail-tick.is-on::after { color: var(--verify-text); }
  .mo-rail-tick:is(:hover, :focus-visible)::after { content: attr(data-n) "  " attr(data-title) / ""; top: 0; padding: 5px 8px; background: var(--obs-forest); color: var(--obs-light-ink); letter-spacing: 0.02em; }
  .mo-rail-readout { position: absolute; inset-inline-start: -4px; top: calc(100% + 24px); width: max-content; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 var(--fs-micro)/1.4 var(--font-sans); letter-spacing: 0.12em; text-transform: uppercase; color: var(--verify-text); }
}

/* ---------- the chapter bar, below 1100 px (spec §5.2) ----------
   The reading position's second view (motion.js): the current section, a copper progress line and
   "Contents" or "Top", on a forest strip at the bottom of the screen. It slides in while the reader is
   inside the article and on keyboard focus, hides while the consent banner shows (as before) and in
   print (register.css). */
@media screen and (max-width: 1099.98px) {
  body.observatory .article-section-nav { --focus: var(--obs-copper); position: fixed; inset: auto 12px calc(12px + env(safe-area-inset-bottom, 0px)); z-index: 145; display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 0 4px 0 16px; overflow: hidden; border: 0; box-shadow: none; background: var(--obs-forest); color: var(--obs-light-ink); font: 500 13px/1.3 var(--font-sans); translate: 0 calc(100% + 24px); transition: translate var(--t-block) var(--ease-out); }
  body.observatory .article-section-nav:is(.is-on, :focus-within) { translate: none; }
  body.observatory .article-section-nav::before { content: none; }
  body.observatory .article-section-current { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  body.observatory .article-section-nav a { display: inline-flex; align-items: center; min-height: 44px; padding-inline: 12px; border: 0; border-inline-start: 1px solid color-mix(in srgb, var(--obs-light-ink) 22%, transparent); color: var(--obs-light-ink); font-size: var(--fs-micro); letter-spacing: 0.1em; text-transform: uppercase; text-decoration: none; }
  .mo-bar-fill { position: absolute; inset: 0 0 auto; height: 2px; background: var(--obs-copper); transform: scaleX(var(--rp, 0)); transform-origin: left center; }
  html.analytics-consent-visible body.observatory .article-section-nav { display: none; }
  /* Keyboard focus scrolls clear of the bar (WCAG 2.4.11); the consent banner keeps its own padding. */
  html:not(.analytics-consent-visible):has(> body > .article-section-nav.is-on) { scroll-padding-bottom: calc(84px + env(safe-area-inset-bottom, 0px)); }
}

/* ---------- figures and tables (spec §5.2) ----------
   An ordered infographic lights its steps in order behind a copper edge, inside its frame (the R1
   marks, above). A comparison table draws a copper rule under each row in turn, then hands back to the
   table's own rule (on stacked phone tables, over each row). Step text dims to a quarter and returns;
   table text never moves (G3). */
@keyframes mo-step { from { opacity: 0.25; box-shadow: inset 0 2px 0 var(--verify); } 60% { box-shadow: inset 0 2px 0 var(--verify); } }
@keyframes mo-rule-draw { from { background-size: 0% 1px; } 70% { background-size: 100% 1px; } }
@media screen {
  html.motion-live :is([data-in]:not(.in) ol[data-sequence], ol[data-in][data-sequence]:not(.in)) > li { opacity: 0.25; }
  html.motion-live :is([data-in].in:not(.is-instant) ol[data-sequence], ol[data-in][data-sequence].in:not(.is-instant)) > li { animation: mo-step var(--t-element) var(--ease-out) calc(var(--mo-s) * 220ms + 200ms) backwards; }
  html.motion-live .compare[data-in].in:not(.is-instant) > tbody > tr > * { background: linear-gradient(var(--verify), var(--verify)) left bottom / 100% 0 no-repeat; animation: mo-rule-draw var(--t-draw) var(--ease-wipe) calc(var(--mo-s) * var(--stagger-row)) backwards; }
}
@media screen and (max-width: 620px) {
  html.motion-live .compare[data-in].in:not(.is-instant) > tbody > tr > * { animation: none; }
  html.motion-live .compare[data-in].in:not(.is-instant) > tbody > tr { background: linear-gradient(var(--verify), var(--verify)) left top / 100% 0 no-repeat; animation: mo-rule-draw var(--t-draw) var(--ease-wipe) calc(var(--mo-s) * var(--stagger-row)) backwards; }
}

/* ---------- the mark: Suhail rising (Living Sky spec §7) ----------
   With the home's first-visit intro only: the gate sets the class before the first frame, so the mark
   never shows and then vanishes. The aperture opens, the horizon draws, and the star rises and glints. */
@media screen {
  html.motion-pending.intro .site-nav .brand-mark > * { transform-box: view-box; transform-origin: 50% 50%; }
  html.motion-pending.intro .site-nav .bm-aperture { animation: mo-mark-open .4s var(--ease-out) backwards; }
  html.motion-pending.intro .site-nav .bm-horizon { animation: mo-mark-draw .4s var(--ease-out) .25s backwards; }
  html.motion-pending.intro .site-nav .bm-star { transform-box: fill-box; animation: mo-mark-rise .65s var(--ease-out) .55s backwards; }
}
@keyframes mo-mark-open { from { opacity: 0; transform: scale(.82); } }
@keyframes mo-mark-draw { from { transform: scaleX(0); } }
@keyframes mo-mark-rise { 0% { opacity: 0; transform: translateY(16px); } 70% { opacity: 1; transform: none; } 85% { transform: scale(1.3); } 100% { transform: none; } }
