/* ============================================================================
   STARMAP — the clean photograph, with ten places to hover. Ledger V3-3.
   ----------------------------------------------------------------------------
   The map is the landing. It fills the viewport top to bottom, the photograph
   zooms as the viewport widens or narrows, and NOTHING is drawn over it except
   ten subtle pulsing glows sitting on the real stars.

   ⛔ NO VISIBLE TEXT, NO FIGURE LINES — AND NO ACCESSIBILITY COST.
   Every star is still an <a> carrying a <title>: real tab order, real
   accessible name, real href a crawler follows. The names never lived in the
   visible labels, so removing them removes presentation and not access. The
   rail is the plainly-labelled route to the same ten pages — which is exactly
   why it was built before this was.

   ⛔ AND NO SCROLL CAPTURE. No wheel handler anywhere; the page scrolls the way
   every page scrolls.
   ========================================================================= */

/* HERO PIN - BUILD NOTE, 2026-08-18. Read before attempting it.
   GOAL: the h1 and the CTA buttons hold still while the paragraph slot advances
   on scroll, and the star overlays land in that slot instead of as absolutely
   positioned popups that collide with text at some widths.
   ARCHITECTURE, mapped rather than assumed:
     .starmap        relative; height:100svh; overflow:hidden; 100vw full-bleed
     .starmap-scroll absolute; inset:0; overflow-x:auto   <- a HORIZONTAL scroller
     .hero-sky       absolute; inset:0                    <- an OVERLAY, not a flow section
   CONSEQUENCE, and it is why this is not a hero-local change: .hero-sky has NO
   SCROLL LENGTH OF ITS OWN, so making the hero sticky is meaningless. The FRAME
   becomes the sticky element inside a taller track:
     <div class="starmap-track">   height:300vh (desktop only)
       <div class="starmap ...">   position:sticky; top:0; height:100svh
   .starmap is already 100svh so it is the natural sticky element, and its own
   overflow:hidden does NOT block this - that would only matter for a sticky
   DESCENDANT; here .starmap itself sticks against .starmap-track.
   WATCH: .starmap carries margin-inline: calc(50% - 50vw) for full bleed and
   .starmap-scroll is a horizontal scroller inside it. Neither should break the
   pin, but both are worth checking FIRST - a sticky element inside a scroll
   container is exactly where this pattern usually fails.
   COST, accepted knowingly by the founder: +200vh of page height before any
   content. Mitigated by scoping the pin to >=1024px; mobile keeps the stacked
   fallback it needs anyway.
   ORDER: (1) wrap the frame and verify the pin ALONE with no swap; (2) the
   scroll-driven swap, behind @supports (animation-timeline: scroll()) AND
   (prefers-reduced-motion: no-preference), with the stack as the unguarded
   default; (3) the star state machine - slot = pinned ?? hovered ?? scroll
   paragraph, ~150ms hover intent, click to pin, Esc to release. */
.starmap {
  position: relative;
  height: 100svh;
  overflow: hidden;             /* the SCROLLER inside does the scrolling */
  background: #01030A;
  width: 100vw;
  margin-inline: calc(50% - 50vw);
}

/* ── THE SCROLLER ─────────────────────────────────────────────────────────
   Horizontal only. The stage is sized to cover, so on any viewport narrower
   than the plate's 1.86 it is wider than the screen and this scrolls; on a
   wider one there is no overflow and the scrollbar never appears.

   ⛔ overflow-y is HIDDEN, not auto. The founder's rule is that the canvas is
   never dragged up and down, and the sizing makes vertical overflow impossible
   below 1.86 anyway — but `auto` on both axes would let a wide viewport scroll
   the sky vertically by a few pixels, which is exactly the wobble that rule
   exists to prevent. */
.starmap-scroll {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;   /* never chain a sideways flick to history */
  touch-action: pan-x pan-y;        /* the page still scrolls vertically */
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--star) 30%, transparent) transparent;
}
.starmap-scroll:focus-visible {
  outline: var(--hair-strong) solid var(--waterline);
  outline-offset: -3px;
}
.starmap-scroll::-webkit-scrollbar { height: 10px; }
.starmap-scroll::-webkit-scrollbar-track { background: color-mix(in srgb, #01030A 60%, transparent); }
.starmap-scroll::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--star) 26%, transparent);
  border-radius: 6px;
}
.starmap-scroll::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--star) 44%, transparent); }

/* Retired: .starmap-pan was the transform wrapper for the old drag. The
   scroller replaces it. Kept as a no-op selector rather than removed so an
   older cached partial does not lose its layer order mid-deploy. */
.starmap-pan {
  position: absolute;
  inset: 0;
  z-index: 2;
  /* ⛔ X ONLY. There is no --sm-y any more: the founder's rule is that the
     canvas must never be draggable up and down, and the sizing above makes
     that free rather than enforced — below the plate's aspect there is no
     vertical overflow to drag. */
  transform: translate3d(var(--sm-x, 0px), 0, 0) scale(var(--sm-k, 1));
  transition: transform 620ms var(--e-threshold);
  will-change: transform;
}
.starmap.is-panning .starmap-pan { transition: none; }

/* ⭐ --sm-k HAD EXACTLY ONE CONSUMER AND IT WAS THE RETIRED WRAPPER ABOVE, so
   the lean-toward-an-engaged-star zoom stopped happening the day .starmap-pan
   stopped being emitted and nothing said so. The stage is what holds the
   layers now.
   ⚠ SCALE ONLY, FROM THE CENTRE. The star markers are positioned inside the
   SVG, so a uniform scale about the centre keeps every marker on its star; a
   translate or a transform-origin off centre would slide them off. */
.starmap-stage {
  transform: scale(var(--sm-k, 1));
  transform-origin: 50% 50%;
  transition: transform 620ms var(--e-threshold);
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .starmap-stage { transition: none; transform: none; }
}

/* ── THE STAGE — sized to COVER, holding both layers ──────────────────────
   Both dimensions take a max() against the plate's own aspect, which fills the
   viewport in whichever direction is short while preserving the ratio exactly:

     1440x900, aspect 1.4664  ->  w max(1440, 1319.8)=1440    h max(900, 982)=982
     900x1200, aspect 1.4664  ->  w max(900, 1759.7)=1759.7   h max(1200, 613.7)=1200

   Both land back on 1.466. This is `object-fit: cover` computed explicitly, and
   it HAS to be explicit: an <img> and an <svg> cannot share one object-fit,
   which is the entire reason they now live in the same box. */
.starmap-stage {
  position: relative;
  margin-inline: auto;
  /* ⛔ COVER, AND THE OVERFLOW IS PANNED RATHER THAN CROPPED AWAY.
     This went min() for a while, which contained the plate and left bands top
     and bottom on a near-square window — the thing the founder photographed.
     max() fills, and with the plate re-cut to 1.86 the overflow is HORIZONTAL
     on every viewport narrower than that, which is the one axis it is
     acceptable to move. Vertical never needs dragging: below 1.86 there is no
     vertical overflow at all, and above it the crop lands in sky margin. */
  width:  max(100vw, calc(100svh * var(--plate-aspect, 1.86)));
  height: max(100svh, calc(100vw / var(--plate-aspect, 1.86)));
}
/* ⛔ PORTRAIT FITS THE FIGURE INSTEAD OF COVERING THE VIEWPORT.
   `cover` crops whichever axis is long, and on a portrait viewport that is the
   HORIZONTAL one — at 900x1200 the stage is 1760px wide against a 900px window,
   so ~430px is cut off each side and the stars living there are unreachable.
   The map deliberately has no drag-to-find and no scroll capture, so a cropped
   star is simply a page nobody can get to from the landing.

   Below the plate's own aspect ratio the stage therefore fits by WIDTH: the
   whole constellation is on screen, every star is reachable, and the space
   above and below is the same near-black the photograph already is, so the
   letterbox does not read as one. */
/* ⛔ THE PLATE ALWAYS FILLS THE VIEWPORT. An earlier revision fitted it by
   WIDTH below the plate's own aspect ratio, so that every star stayed on
   screen in portrait — and the cost was a dark band above and below the
   photograph, plus the next section leaking into view under it. Founder
   verdict: the photograph stays filled.

   ⚠ THE TRADE IS REAL AND IS NOT HIDDEN. `cover` crops the long axis, so on a
   narrow portrait window the outermost stars go off screen. No page becomes
   unreachable — the rail carries all ten as labelled links, which is exactly
   the role it was built for — but on a phone the constellation is a detail of
   the sky rather than the whole figure. That is the deal: a filled frame here,
   the rail for completeness.
   The stage geometry above needs no media query to do this; cover is its
   default behaviour. */

.starmap-plate,
.starmap-plate img,
.starmap-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.starmap-svg { overflow: visible; }
.starmap-plate { pointer-events: none; }

/* The photograph carries itself. Held just under full strength so the glows
   have somewhere to sit — this plate has thousands of real stars in it, and an
   unmodified field competes with its own markers. */
.starmap-plated .starmap-plate { opacity: .88; }

/* A soft vignette, no more. Enough to seat the frame and let what follows
   arrive without a hard edge; not enough to read as a wash over a photograph
   that was shot with a diffuser pass specifically so its colours register. */
.starmap-plated::after {
  content: '';
  position: absolute; inset: 0; z-index: 3;
  pointer-events: none;
  background:
    radial-gradient(96% 88% at 50% 46%, transparent 52%,
      color-mix(in srgb, #01030A 46%, transparent) 100%),
    linear-gradient(180deg, color-mix(in srgb, #01030A 40%, transparent) 0%,
      transparent 14%, transparent 88%, #01030A 100%);
}

/* ── THE GLOWS — the only thing drawn ─────────────────────────────────────
   A star already in the photograph does not need a marker on top of it; it
   needs to be pointed at. So the disc is invisible and the GLOW is the whole
   affordance: a slow breath over the real star, each on its own phase and
   period so ten of them read as a sky rather than as a control panel. */
.sm-star { cursor: pointer; }
.sm-hit  { fill: transparent; }

.starmap-plated .sm-disc { fill: transparent; }
.starmap-plated .sm-name,
.starmap-plated .sm-bayer,
.starmap-plated .sm-lines { display: none; }

.sm-glow {
  opacity: .5;
  /* ⛔ BOTH OF THESE ARE LOAD-BEARING FOR AN SVG TRANSFORM. Without fill-box the
     transform-origin resolves against the SVG's own viewport, not the circle,
     so a scale animation slides the glow across the frame instead of pulsing it
     in place. `transform`, not the `scale` property, because it composites the
     same way and is the better-supported spelling inside SVG. */
  transform-box: fill-box;
  transform-origin: center;
  transition: opacity 300ms var(--e-out);
}
/* ⛔ THIS RUNS FOR EVERYONE, and the reduced-motion variant below is why.
   The animation was gated entirely on `prefers-reduced-motion: no-preference`,
   so on a machine with animation effects turned off — which Windows 11 offers
   as a single accessibility toggle, and which the founder has on — the glows
   sat perfectly still and the only affordance on the landing page vanished.
   Nothing said so; the page simply had ten invisible controls.

   The vestibular concern that setting exists for is MOVEMENT: scaling,
   parallax, travel across the screen. A slow change in brightness is not that.
   So the full swell (scale + opacity, 7-14s) runs by default, and under
   reduced motion the same rhythm runs as OPACITY ONLY, with no scaling at all.
   The affordance survives; the movement does not. */
.sm-glow {
  animation: star-twinkle var(--pe, 9s) var(--e-inout) infinite;
  animation-delay: var(--ph, 0s);
}
@media (prefers-reduced-motion: reduce) {
  .sm-glow { animation-name: star-breathe-still; }
}

/* ── THE ENTRANCE ───────────────────────────────────────────────
   Every rule here hangs off .sm-entrance, which only starrmap.js adds and only
   after its reduced-motion check. Script blocked, JS disabled, animation
   effects off — none of this applies and all ten stars are drawn as delivered.

   ⛔ THE TAIL IS THE STAR'S OWN GLOW, NOT A NEW ELEMENT. The stars are SVG and
   the markup is generated; adding a trail element would mean a second thing to
   position, keep in sync with the star, and remember to hide. Stretching the
   existing glow along the direction of travel gives the streak for free and it
   contracts back to a point on landing, which is exactly what a comet does.
   transform-box: fill-box so the stretch is around the glow's own centre and
   not the SVG origin — without it the tail flies off the canvas. */
/* ══ THE ENTRANCE: ONE STAR PER SECOND, EACH ON A PULSE ══
   ⛔ THE COMET IS RETIRED, ON FOUNDER DIRECTION, AFTER FIVE ATTEMPTS.
   It was fixed five times and read as a millisecond blip every time: the arc
   reshaped twice, duration 1150 -> 1600ms, easing corrected twice (a
   non-monotone curve, then per-segment troughs), the head 3px -> 27px, the
   tail from a symmetric radial smear to a trailing streak. Every fix was real
   and none of them answered the complaint.

   The property they all shared is that it MOVED. A moving object has a speed,
   and speed is what kept reading as a blip. A pulse has no speed — only a
   duration and a cadence, both single numbers, neither open to misjudgement
   by the person watching.

   THE CADENCE IS THE DESIGN: ten stars, one per second, each brightening from
   nothing to a brief overshoot and settling. A full second per star is what
   makes it read as deliberate placement rather than a loading screen.
   ⚠ Ten seconds end to end, on purpose, and it is ONE number to change. */
:root {
  /* 560ms: ten stars in ~5.6s. One per second read as deliberate on the
     first viewing and slow on every one after it. */
  --sm-cadence: 560ms;
  /* ⛔ 620ms, NOT 900ms. At 900 against a 1000ms cadence the tail of one
     star's pulse was still decaying as the next star appeared, which is
     exactly the report: "the pulse of the previous star and the point for the
     next star show up at the same time". A pulse must finish inside its own
     second, with a clear beat of stillness after it. */
  --sm-pulse: 620ms;
}

/* ══ THE PRE-SCRIPT HIDE ══════════════════════════════════════
   ⛔ THE STYLESHEETS LAND BEFORE THE DEFERRED SCRIPT RUNS, AND THE MAP
   PAINTED IN THE GAP. Measured, throttled to 1.2Mbps at 2560x1440: all ten
   stars visible at 1859ms, all ten hidden at 1903ms, then revealed one per
   second. Forty-four milliseconds of complete constellation, then nothing,
   then a rebuild — which is worse than either behaviour alone and is very
   likely part of what has been reported as a flash on load.
   starmap.js adding .sm-entrance "immediately" cannot close it: immediately is
   still after the CSS applies.

   ⚠ THREE CASES, AND THIS IS SAFE IN ALL THREE — the rule that matters here,
   because this exact surface has already lost its only navigation once:
     reduced motion         — the media query excludes it, so the stars are
                              never hidden and there is no entrance to miss
     no-preference + JS     — hidden here, revealed by the pulse, and
                              html.sm-ready lifts the rule at teardown
     no-preference + NO JS  — the <noscript> block below restores them, so a
                              reader without scripts sees the full map
   ⛔ DO NOT DROP THE MEDIA QUERY OR THE NOSCRIPT. Either one alone turns this
   into "the page has no controls and nothing says so". */
@media (prefers-reduced-motion: no-preference) {
  html:not(.sm-ready) .starmap .sm-star { opacity: 0; }
}

/* Hidden by a class this stylesheet never applies on its own — starmap.js
   adds it after its reduced-motion check, so animation-effects-off leaves all
   ten stars exactly as delivered. */
.sm-entrance .sm-star { opacity: 0; }

.sm-entrance-landed .sm-star {
  animation: sm-star-pulse var(--sm-pulse) cubic-bezier(.16, 1, .3, 1) both;
}

/* Mira first — the assistant is named for omicron Ceti, the one position on
   this map that is not negotiable — then the constellation's own reading
   order, which is the DOM order the generator emits. */
.sm-entrance-landed .sm-star-mira,
.sm-entrance-landed .sm-star-mira .sm-glow { animation-delay: 0ms; }
.sm-entrance-landed .sm-star:nth-of-type(1),
.sm-entrance-landed .sm-star:nth-of-type(1) .sm-glow { animation-delay: calc(var(--sm-cadence) * 1); }
.sm-entrance-landed .sm-star:nth-of-type(2),
.sm-entrance-landed .sm-star:nth-of-type(2) .sm-glow { animation-delay: calc(var(--sm-cadence) * 2); }
.sm-entrance-landed .sm-star:nth-of-type(3),
.sm-entrance-landed .sm-star:nth-of-type(3) .sm-glow { animation-delay: calc(var(--sm-cadence) * 3); }
.sm-entrance-landed .sm-star:nth-of-type(5),
.sm-entrance-landed .sm-star:nth-of-type(5) .sm-glow { animation-delay: calc(var(--sm-cadence) * 4); }
.sm-entrance-landed .sm-star:nth-of-type(6),
.sm-entrance-landed .sm-star:nth-of-type(6) .sm-glow { animation-delay: calc(var(--sm-cadence) * 5); }
.sm-entrance-landed .sm-star:nth-of-type(7),
.sm-entrance-landed .sm-star:nth-of-type(7) .sm-glow { animation-delay: calc(var(--sm-cadence) * 6); }
.sm-entrance-landed .sm-star:nth-of-type(8),
.sm-entrance-landed .sm-star:nth-of-type(8) .sm-glow { animation-delay: calc(var(--sm-cadence) * 7); }
.sm-entrance-landed .sm-star:nth-of-type(9),
.sm-entrance-landed .sm-star:nth-of-type(9) .sm-glow { animation-delay: calc(var(--sm-cadence) * 8); }
.sm-entrance-landed .sm-star:nth-of-type(10),
.sm-entrance-landed .sm-star:nth-of-type(10) .sm-glow { animation-delay: calc(var(--sm-cadence) * 9); }

@keyframes sm-star-pulse {
  /* ⛔ THE DOT AND THE FLASH ARRIVE TOGETHER. The previous version reached
     peak brightness at 18% and full opacity at the same frame, which sounds
     synchronous and is not: for the first 18% the star was FADING UP while
     already bright, so the eye read the arrival and the flash as two events a
     tenth of a second apart. Opacity is now full by 8% — about 50ms, below the
     threshold at which an appearance reads as a fade — and brightness is at
     its peak on that same frame. One arrival, lit, then a single decay. */
  0%   { opacity: 0; filter: brightness(var(--sm-bright, 2.8)); }
  8%   { opacity: 1; filter: brightness(var(--sm-bright, 2.8)); }
  100% { opacity: 1; filter: brightness(1); }
}

/* ⛔ THERE IS NO SEPARATE GLOW ANIMATION, AND THAT IS THE FIX.
   The sparkle used to be sm-glow-flare on .sm-glow. .sm-glow already runs
   `scintillate` at its resting phase, so during the entrance the glow's
   animation-name was flare and afterwards it was scintillate — and CHANGING
   animation-name RESTARTS the animation. Every teardown therefore dropped each
   glow back into scintillate at an arbitrary phase, and any phase inside its
   opening 8% is a burst. Measured: stars 7 and 9 kept flashing again seconds
   after they had arrived, with nothing on screen to explain it.
   ⚠ Nothing now animates the glow except scintillate, which runs uninterrupted
   from first paint at the phase build-starmap.js computed. The teardown removes
   only the <a>'s pulse, which has already finished by then.

   THE SIZE VARIATION MOVED TO BRIGHTNESS. --sm-bright is emitted per star from
   the same magnitude that drove --sm-flare, and it animates `filter` on the
   <a> — a property scintillate does not touch, so the two cannot collide. A
   brighter flash blooms a soft glow wider, which is the variation that was
   asked for, without a second animation competing for transform. */
/* ══ THE TWINKLE PHASE IS NOT SET HERE ════════════════════════════
   ⛔ NINETEEN OVERRIDE RULES USED TO LIVE HERE AND THEY CAUSED A FLASH AT
   TEARDOWN. They set each star's twinkle animation-delay so it would begin
   past scintillate's opening burst — correct while .sm-entrance was applied,
   and undone the moment it was removed, at which point the delay reverted to
   var(--ph) and the animation RESTARTED at a different phase. Measured: star 9
   burst a second time 1782ms after it appeared, with nothing on screen to
   explain it.
   The phase is now computed once, in build-starmap.js's phaseFor(), and
   emitted inline as --ph/--ph2. One value, correct during the entrance and
   after it, and the teardown changes nothing.
   ⚠ Only the fill-mode stays: it makes the calm 0% frame show during the wait
   rather than the element's unanimated state, and it is phase-independent. */
/* ⛔ THE TWINKLE IS FROZEN FOR THE WHOLE ENTRANCE, NOT RE-PHASED.
   Three attempts re-timed it instead: delay the twinkle past its burst,
   compute the phase in the builder, drop the competing flare. Each removed one
   flash and left another, because all three shared an assumption — that the
   twinkle should be RUNNING while the stars arrive. It should not. While it
   runs, the reveal is competing with a three-peak burst for the reader's
   attention, and any scheduling error puts the two on top of each other.

   ⚠ PAUSED, NOT CANCELLED, AND THE DIFFERENCE IS THE WHOLE POINT.
   animation-play-state: paused FREEZES the animation at its current frame;
   removing .sm-entrance RESUMES it from that same frame. Nothing restarts, so
   nothing jumps — which is exactly what changing animation-name or
   animation-delay did, and what produced a burst seconds after a star had
   already arrived.
   The frozen frame is calm because build-starmap.js gives every star a
   negative --ph inside the 15-55% band, past both opening bursts. So each star
   appears at its resting size with only its brightness pulse moving, and the
   field starts twinkling together when the entrance ends, each from its own
   calm phase. */
.sm-entrance .sm-star .sm-glow,
.sm-entrance .sm-star .sm-spike-t,
.sm-entrance .sm-star .sm-spike {
  /* ⛔ THE PAUSE IS GONE. It froze every twinkle until the LAST star landed,
     so the first star was motionless for nine seconds. With the burst moved
     off the front of the cycle there is nothing to hide from: each twinkle
     simply waits for its own star and then runs.
     backwards keeps the calm 0% frame showing during that wait rather than
     the element's unanimated state. */
  animation-fill-mode: backwards;
}
.sm-entrance .sm-star-mira .sm-glow,
.sm-entrance .sm-star-mira .sm-spike-t,
.sm-entrance .sm-star-mira .sm-spike { animation-delay: 0ms; }


@keyframes star-breathe-still {
  0%   { opacity: .34; }
  45%  { opacity: .95; }
  100% { opacity: .34; }
}
/* A real swell, not a shimmer. The range is wide enough to read as growing and
   shrinking rather than as a brightness flicker, and the period is long enough
   (7-14s, varied per star) that it never reads as a UI element blinking for
   attention. Four stops rather than two so the swell has a shape — a slow rise,
   a held peak and a longer fall is what a star does; a sine wave is what a
   loading indicator does. */
@keyframes star-twinkle {
  0%   { opacity: .26; transform: scale(.74); }
  38%  { opacity: .92; transform: scale(1.20); }
  52%  { opacity: 1;   transform: scale(1.26); }
  100% { opacity: .26; transform: scale(.74); }
}

/* Engaged: the glow steadies and brightens rather than growing a ring. The
   photographed star stays the thing being looked at. */
.sm-star:hover .sm-glow,
.sm-star:focus-visible .sm-glow,
.sm-star.is-active .sm-glow { opacity: 1; animation-play-state: paused; }

/* ⛔ KEYBOARD FOCUS MUST BE VISIBLE, and a glow brightening is not a focus
   indicator — it is indistinguishable from a hover and useless to anyone who
   cannot perceive the change. Tabbing draws an explicit ring. */
.sm-star:focus { outline: none; }
.sm-star:focus-visible .sm-disc {
  fill: none;
  stroke: var(--waterline);
  stroke-width: 2.5;
  vector-effect: non-scaling-stroke;
  r: 15;
}

/* Mira breathes on its own terms — the variable star, and the one place on this
   map where the motion is the fact rather than the affordance. */
.sm-star-mira .sm-glow { animation-duration: 9.4s; }

/* ── THE OVERVIEW CARD ────────────────────────────────────────────────────
   🪤 `hidden` does not beat a class selector: [hidden]{display:none} is element
   specificity, so a bare `.starmap-card{display:grid}` overrode it and left the
   card on every page at opacity:0 with real height — invisible, laid out, and
   in the accessibility tree. Caught by the a11y suite. */
.starmap-card[hidden] { display: none; }
.starmap-card {
  position: absolute;
  z-index: 5;
  width: min(30rem, 78vw);
  display: grid;
  grid-template-columns: 9.5rem minmax(0, 1fr);
  gap: 0 1rem;
  padding: .85rem;
  border: 0;
  border-radius: var(--r-surface);
  /* Blur alone over a light wash. No hard border and no brightness knock — the
     knock was darkening everything behind the panel, which is why a genuinely
     translucent fill still read as a solid card. The edge is an inner
     highlight, not a drawn line.
     ⚠ The a11y suite measures real contrast against whatever ground resolves
     here, so fill and blur are a balance rather than a free dial. */
  background: color-mix(in srgb, #060C22 34%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--star) 10%, transparent);
  backdrop-filter: blur(34px) saturate(1.2);
  -webkit-backdrop-filter: blur(34px) saturate(1.2);
  /* ⛔ THE CARD IS REACHABLE. It was pointer-events:none so it could never eat
     the pointer off a star — which also meant the reader could not move onto
     it, could not click 'Open', and watched it swap to a different page every
     time the cursor crossed another star on the way there. It is a real
     target now, and starmap.js locks the selection while the pointer is on
     it. It sits bottom-left, away from the figure, so it rarely covers a star
     at all. */
  pointer-events: auto;
  cursor: pointer;
  opacity: 0;
  translate: 0 .5rem;
  transition: opacity 200ms var(--e-out), translate 200ms var(--e-out);
}
.starmap-card.is-shown { opacity: 1; translate: 0 0; }
@media (prefers-reduced-motion: reduce) {
  .starmap-card { transition: none; translate: 0 0; }
}

.smc-media { overflow: hidden; border-radius: calc(var(--r-surface) - 2px); background: var(--deep); }
.smc-media img { display: block; width: 100%; height: 100%; object-fit: cover; aspect-ratio: 4 / 3;
                 border-radius: 10px; }
.smc-bayer { margin: 0 0 .2rem; color: var(--star-dim); font-size: var(--m-tick); letter-spacing: .05em; }
.smc-title { margin: 0 0 .4rem; }
.smc-text  { margin: 0; font-size: .92rem; line-height: 1.5;
             color: color-mix(in srgb, var(--star) 84%, transparent); }
.smc-go    { margin: .6rem 0 0; color: var(--waterline); font-size: var(--m-tick); letter-spacing: .05em; }

/* ── NARROW ───────────────────────────────────────────────────────────────
   The stage already covers, so the photograph simply crops tighter. The card
   pins to the bottom, and starmap.js requires a second tap before following the
   link because a hover card means nothing on touch. */
@media (max-width: 700px) {
  .starmap { height: 88svh; }
  .starmap-card {
    left: .6rem; right: .6rem; bottom: .6rem; top: auto;
    width: auto; grid-template-columns: 6.5rem minmax(0, 1fr);
  }
}

/* ── NO PLATE: the procedural fallback keeps its figure and its labels ────
   Without the photograph there is nothing to point AT, so the drawn chart IS
   the map and the labels are the only way to read it. Kept working rather than
   deleted: the plate is a licensed third-party asset, and a site that cannot
   render without it is a site with a single point of failure it does not own. */
.starmap:not(.starmap-plated) .sm-lines line {
  stroke: color-mix(in srgb, var(--current) 38%, transparent);
  stroke-width: 1; vector-effect: non-scaling-stroke;
}
.starmap:not(.starmap-plated) .sm-name,
.starmap:not(.starmap-plated) .sm-bayer {
  paint-order: stroke; stroke: #0B1130; stroke-width: 5px;
  stroke-linejoin: round; pointer-events: none;
}
.starmap:not(.starmap-plated) .sm-name {
  font-family: var(--f-display); font-size: 19px;
  fill: color-mix(in srgb, var(--star) 74%, transparent);
}
.starmap:not(.starmap-plated) .sm-bayer {
  font-family: var(--f-data); font-size: 12.5px; letter-spacing: .04em;
  fill: color-mix(in srgb, var(--star-dim) 78%, transparent);
}
.starmap:not(.starmap-plated) .sm-disc { fill: var(--star); }
.starmap-hint {
  position: absolute; left: 50%; translate: -50% 0;
  bottom: clamp(1.4rem, 5vh, 3rem); z-index: 4;
  font-family: var(--f-data); font-size: var(--m-tick); letter-spacing: .05em;
  color: color-mix(in srgb, var(--star-dim) 92%, transparent);
  pointer-events: none; transition: opacity 400ms var(--e-out);
}
.starmap.is-engaged .starmap-hint { opacity: 0; }

/* ══ LANDING MODE ═══════════════════════════════════════════════════════════
   The map is the entire page. Nothing scrolls, so there is no scrollbar — which
   is also what stops the map shifting sideways as the window narrows, the fault
   that kept hiding stars off the left edge. */
/* ⛔ THE PAGE SCROLLS NOW, AND THIS PAIR IS WHAT STOPPED IT.
   Measured by the audit: document.scrollHeight equalled the viewport at both
   1440 and 390, so there was no page below the fold at all — no proof, no
   programs, no footer in the layout.
   That was deliberate ("a landing page that scrolls is not a landing page")
   and the founder has now reversed it, choosing a proof band and a programs
   index below the map. The map keeps the full first screen; everything after
   it is ordinary document flow. */
.is-landing main { min-height: 100svh; }
/* The footer stays on screen because two of its lines are obligations: the
   pre-revenue frame the content gate requires on every page, and the NOIRLab
   credit CC BY requires to be visible. Overlaid, small, no ground. */
.is-landing .site-foot {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 4;
  background: none; padding: 0 0 .55rem;
  pointer-events: none;
}
.is-landing .site-foot .waterline { display: none; }
.is-landing .site-foot-in {
  display: flex; flex-wrap: wrap; justify-content: center; gap: .2rem 1.1rem;
  /* 9rem, not 8.5rem: folded up from a later unconditional rule (CP-R1605qd-S). It was declared
     AFTER the max-width:700px override below, at equal specificity — and a media query adds no
     specificity, so source order decided and the narrow-viewport rule was DEAD AT EVERY WIDTH.
     Measured on a 390px viewport before the fix: padding 32px 144px left a 102px content column,
     which wrapped the honest-frame disclosure to 3-4 words per line and 155px tall, underneath a
     225px fixed footer covering 27% of an 844px screen. The rule that would have prevented it was
     already written; it just never applied. */
  padding-inline: 9rem;            /* clear of the wordmark and the menu */
  text-align: center;
}
.is-landing .site-foot p {
  margin: 0;
  font-size: .66rem;
  line-height: 1.35;
  color: color-mix(in srgb, var(--star) 40%, transparent);
  text-shadow: 0 1px 3px rgba(0,0,0,.9);
}
.is-landing .site-foot a { color: color-mix(in srgb, var(--star) 58%, transparent); pointer-events: auto; }

/* ⛔ THE DISCLOSURE IS NOT A CREDIT, AND ON THE LANDING IT WAS BEING TREATED AS ONE.
   CP-R1605ov-S, FD-017. The rule above is right for what it was written for — credit and
   contact lines over a photograph, deliberately quiet. It matched the honest frame too,
   and MEASURED at 1440x900 the landing rendered it at 10.56px in var(--star) at 40% alpha:
   credit size, low contrast. That is the exact pair the founder rejected twice, reproduced
   by my own fix on one page of twenty — found by measuring the rendered element, not by
   reading the markup, which is what the visual-verification canon is for.
   Specificity: the rule above is (0,2,1); this is (0,3,1) and wins. The text-shadow is KEPT
   because it is what makes any text legible over the photograph. */
.is-landing .site-foot p.foot-honest {
  font-size: .8rem;
  color: var(--star);
  max-width: 44rem;
  margin-inline: auto;
}
@media (max-width: 700px) {
  /* ⭐ THE CLEARANCE IS VERTICAL, NOT HORIZONTAL, and that is the whole correction (CP-R1605qd-S).
     The 9rem inline padding existed "clear of the wordmark and the menu" — it cleared them
     SIDEWAYS, which costs 288px of a 390px viewport and strangles the text to a 102px column.
     But the chrome is BOTTOM-ANCHORED: measured at 390x844 the wordmark owns y 782-825 and the
     menu y 777-830, so the band to avoid is the bottom ~67px, not the left and right thirds.
     Clearing it with padding-bottom costs nothing horizontally and the text keeps the full width. */
  .is-landing .site-foot-in { padding-inline: 1rem; padding-bottom: 4.25rem; }
  .is-landing .site-foot p { font-size: .6rem; }
}

/* ══ THE DIM ════════════════════════════════════════════════════════════════
   15% at rest so the sky sits behind the interface rather than competing with
   it; 50% and blurred the moment a star is engaged, so the card's text reads as
   floating on the surface of the sky rather than sitting in a panel on top of
   it. The blur is on the IMAGE LAYERS only — blurring the container would blur
   the card as well. */
.starmap-plate, .starmap-back {
  transition: opacity 420ms var(--e-out), filter 420ms var(--e-out);
}
.starmap-plated .starmap-plate { opacity: .85; }
/* ⚠ HALVED FROM THE FIRST PASS. It ran at .5 opacity and 7px blur, which the
   founder read as too strong — the photograph stopped being a photograph. Both
   are now half that departure from rest: the dim goes .85 -> .675 rather than
   .85 -> .5, and the blur is 3.5px. Enough separation for the card to read,
   little enough that the sky is still the thing behind it. */
.starmap-plated.is-engaged .starmap-plate,
.starmap-plated.is-engaged .starmap-back {
  opacity: .675;
  filter: blur(3.5px);
}
/* the glows dim with the sky, so the engaged one is the only bright thing */
.starmap.is-engaged .sm-star:not(.is-active) .sm-glow { opacity: .3; }

/* ══ THE BACKDROP ═══════════════════════════════════════════════════════════
   Same photograph, scaled to COVER and blurred, sitting under the sharp plate
   which is scaled to CONTAIN. Cover alone crops stars off a narrow window;
   contain alone leaves dark bands. Together the frame is always full and all
   ten stars are always on screen, at any aspect ratio. */
.starmap-back {
  position: absolute; inset: 0; z-index: 0;
  width: 100%; height: 100%;
  pointer-events: none;
}
.starmap-back img {
  width: 100%; height: 100%;
  object-fit: cover;
  filter: blur(26px) saturate(1.1) brightness(.62);
  transform: scale(1.12);      /* hide the blur's soft edge */
}

/* ══ THE CARD ═══════════════════════════════════════════════════════════════
   No ground, no border, no blur panel. The sky behind it is already dimmed and
   blurred, which is what makes the text legible — so the text simply sits on
   it. A panel here would undo the dim it was given. */
/* ⛔ ONE FIXED PLACE, ALWAYS. Bottom left, sitting above the wordmark — the
   same corner every time, so the reader's eye learns it once instead of
   re-finding the panel on every hover. */
.starmap-card {
  left: clamp(1rem, 2.4vw, 2.2rem);
  right: auto;
  top: auto;
  bottom: calc(clamp(.9rem, 2.2vh, 1.8rem) + 2.6rem);
  width: min(46rem, 82vw);
  grid-template-columns: 15rem minmax(0, 1fr);
  gap: 0 1.6rem;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
.smc-media { border-radius: 10px; box-shadow: 0 18px 50px rgba(0,0,0,.55); }
.smc-title {
  font-size: clamp(1.9rem, 3.1vw, 3rem);
  line-height: 1.02;
  letter-spacing: -0.02em;
  margin: 0 0 .7rem;
  text-shadow: 0 2px 18px rgba(0,0,0,.7);
}
.smc-text {
  font-size: clamp(1rem, 1.18vw, 1.15rem);
  line-height: 1.5;
  color: color-mix(in srgb, var(--star) 92%, transparent);
  text-shadow: 0 1px 12px rgba(0,0,0,.75);
}
.smc-go { margin-top: .9rem; text-shadow: 0 1px 10px rgba(0,0,0,.8); }
@media (max-width: 700px) {
  .starmap-card { grid-template-columns: minmax(0, 1fr); gap: .9rem 0; width: auto; }
  .smc-media { max-width: 62vw; }
}

/* ══ THE SEQUENCE ARROWS ════════════════════════════════════════════════════
   ⛔ EDGE OVERLAYS, NOT A CONTROL BAR. They sat centred at the bottom with a
   "4 / 10" counter, which is the furniture of a slide deck — it framed the sky
   as a presentation and told the reader how much was left, neither of which is
   wanted. They are now two chevrons at the left and right edges, vertically
   centred, the shape a reader already reaches for on an image. The counter is
   gone entirely. */
.starmap-seq { display: contents; }
.seq-count { display: none; }
.seq-btn {
  position: absolute;
  top: 50%; translate: 0 -50%;
  z-index: 6;
  width: clamp(3rem, 5vw, 4.4rem); height: clamp(5rem, 12vh, 8rem);
  display: grid; place-items: center;
  border: 0; background: none; padding: 0;
  color: color-mix(in srgb, var(--star) 70%, transparent);
  cursor: pointer;
  filter: drop-shadow(0 1px 5px rgba(0,0,0,.95));
  transition: color var(--t-hover) var(--e-out), opacity var(--t-hover) var(--e-out);
  opacity: .38;
}
.seq-prev { left: clamp(.2rem, 1.4vw, 1.4rem); }
.seq-next { right: clamp(.2rem, 1.4vw, 1.4rem); }
.seq-btn svg { width: 22px; height: 22px; }
.seq-btn:hover { color: var(--star); opacity: 1; }
.seq-btn:focus-visible { outline: var(--hair-strong) solid var(--waterline); outline-offset: 3px; opacity: 1; }
.seq-count {
  font-family: var(--f-data); font-size: var(--m-tick); letter-spacing: .08em;
  min-width: 4.5rem; text-align: center;
  filter: drop-shadow(0 1px 4px rgba(0,0,0,.9));
  opacity: .7;
}

/* The opening locks the page while it is up. Released by threshold.js on
   dismissal, and never applied at all when it skips. */
html.th-locked, html.th-locked body { overflow: hidden; }

/* ══ THE IMAGE CREDIT ═══════════════════════════════════════════════════════
   In the corner of the photograph it credits, rather than in the footer.
   ⚠ SMALL AND DIM IS A FLOOR, NOT A TARGET. CC BY 4.0 asks for attribution
   that is "clear and visible", which is a condition of using the image at all
   rather than a matter of taste — so it is quiet, and it is still real legible
   text with both links live. It does not shrink further, it does not hide
   behind a hover, and it does not fade out. */
.img-credit {
  position: fixed;
  top: clamp(.6rem, 1.4vh, 1.1rem);
  left: clamp(.8rem, 1.8vw, 1.6rem);
  z-index: 7;
  margin: 0;
  max-width: min(42ch, 62vw);
  font-family: var(--f-data);
  font-size: .62rem;
  line-height: 1.35;
  letter-spacing: .02em;
  color: color-mix(in srgb, var(--star) 34%, transparent);
  text-shadow: 0 1px 3px rgba(0,0,0,.9);
  pointer-events: none;
}
.img-credit a { color: color-mix(in srgb, var(--star) 48%, transparent); pointer-events: auto; }
.img-credit a:hover { color: var(--star); }

/* The landing footer now carries only what is left of it.
   ⛔ ITS padding-inline: 9rem MOVED UP INTO THE BASE RULE (CP-R1605qd-S). Sitting here it came
   AFTER `@media (max-width: 700px) { … padding-inline: 1rem }` at equal specificity, so it won at
   every width and the phone layout never got its 1rem. A property set in three places, with the
   narrow override in the middle, is the shape to watch for: the last one wins regardless of the
   media query, because @media contributes nothing to specificity. */

/* The honest frame on the landing: the three required words, nothing else,
   stacked under the image credit at the same weight. Not a sentence. */
.img-credit-frame { top: auto; bottom: clamp(.6rem, 1.4vh, 1.1rem); }

/* ══ THE VIGNETTE, AND THE DEFAULT LEVEL ════════════════════════════════════
   A real vignette rather than the faint corner wash that was there. It does two
   jobs: seats the photograph as an image instead of a wallpaper, and darkens
   exactly the edges the navigation arrows live on — which was half of why they
   were disappearing into the field.

   The plate also drops 10% at rest. A star field is bright across its entire
   area, which is unusual for a background and is why nothing placed on it sat
   comfortably. */
.starmap-plated .starmap-plate { opacity: .85; filter: brightness(.9); }
.starmap-plated.is-engaged .starmap-plate,
.starmap-plated.is-engaged .starmap-back { opacity: .675; filter: blur(3.5px) brightness(.9); }

.starmap-plated::after {
  background:
    radial-gradient(122% 110% at 50% 48%,
      transparent 32%,
      color-mix(in srgb, #01030A 32%, transparent) 64%,
      color-mix(in srgb, #01030A 74%, transparent) 87%,
      color-mix(in srgb, #01030A 94%, transparent) 100%),
    linear-gradient(180deg, color-mix(in srgb, #01030A 46%, transparent) 0%,
      transparent 16%, transparent 86%, color-mix(in srgb, #01030A 58%, transparent) 100%);
}

/* ══ THE ARROWS ═════════════════════════════════════════════════════════════
   1.4px at 38% opacity over a field of point sources simply vanished. Heavier
   stroke, brighter at rest, and a soft dark disc behind the glyph so they hold
   wherever on the sky they land. */
.seq-btn { opacity: .8; color: color-mix(in srgb, var(--star) 94%, transparent); }
.seq-btn svg { width: 30px; height: 30px; }
.seq-btn::before {
  content: '';
  position: absolute; top: 50%; left: 50%;
  translate: -50% -50%;
  width: 3.2rem; height: 3.2rem;
  border-radius: 50%;
  background: radial-gradient(circle, color-mix(in srgb, #01030A 66%, transparent) 0%, transparent 72%);
  z-index: -1;
}
.seq-btn:hover { opacity: 1; color: #FFFFFF; }

/* ══ THE CREDIT ═════════════════════════════════════════════════════════════
   Was wrapping to two rows at 42ch. Given room to sit on one line. */
.img-credit { max-width: min(100ch, 90vw); font-size: .64rem; white-space: nowrap; }
@media (max-width: 900px) { .img-credit { white-space: normal; max-width: 92vw; } }

/* ══ A DRAWN CONSTELLATION NEEDS A STAR, NOT JUST A HALO ════════════════════
   On the solved plate the marker sat ON a photographed star, so a soft glow
   was a halo around something already there and the disc was hidden on purpose
   — a filled dot would have covered the very thing being pointed at.

   On a drawn plate there is nothing underneath. The marker IS the star, and a
   halo with no core reads as lens bokeh rather than as a point of light. So the
   disc comes back as a small bright core, and the halo tightens around it. */
.starmap-drawn .sm-disc {
  fill: #F4F8FF;
  r: 2.6;
  filter: drop-shadow(0 0 3px rgba(210,230,255,.9));
}
.starmap-drawn .sm-star-mira .sm-disc { fill: #FFF3DE; r: 3.2; }
/* Tighter than the solved plate's halo: 0.62 of the drawn radius, so the swell
   still reads at a glance without spreading into a smudge the size of a moon. */
.starmap-drawn .sm-glow { opacity: .42; transform: scale(.62); }
@keyframes star-twinkle-drawn {
  0%   { opacity: .22; transform: scale(.46); }
  38%  { opacity: .72; transform: scale(.74); }
  52%  { opacity: .82; transform: scale(.80); }
  100% { opacity: .22; transform: scale(.46); }
}
@media (prefers-reduced-motion: no-preference) {
  .starmap-drawn .sm-glow { animation-name: star-twinkle-drawn; }
}
.starmap-drawn .sm-star:hover .sm-disc,
.starmap-drawn .sm-star:focus-visible .sm-disc,
.starmap-drawn .sm-star.is-active .sm-disc { fill: #FFFFFF; r: 3.6; }

/* ══ THE TWINKLE ════════════════════════════════════════════════════════════
   What was here was a PULSE: a slow sinusoidal breath over 7-14s. Physically
   and visually those are different things — scintillation is short, sharp and
   irregular, caused by air moving in front of a point source, and a smooth
   swell reads as a lamp on a dimmer instead.

   Three changes, and the third is the one that matters most:
     1. the flicker is a burst of uneven steps occupying under a second
     2. a diffraction spike flashes with it — most of the "star" read comes
        from the spike, not the brightness
     3. the star and its spike run on periods that do NOT divide each other, so
        the pair only realigns after their lowest common multiple. CSS has no
        randomness; this is how you get asynchrony without a script, and with
        ten stars something twinkles roughly every second and a half.

   ⚠ SIZE: cores and glows were about 3x too large — measured against the
   photograph's own stars they read as planets. Core radius 2.6 -> 0.85 and the
   halo scales to match. */
.starmap-drawn .sm-disc {
  r: 0.85;
  filter: drop-shadow(0 0 1.5px rgba(210,230,255,.85));
}
.starmap-drawn .sm-star-mira .sm-disc { r: 1.05; }
.starmap-drawn .sm-glow { opacity: .3; transform: scale(.2); }

.sm-spike {
  stroke: #EAF2FF;
  stroke-width: .7;
  vector-effect: non-scaling-stroke;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .starmap-drawn .sm-glow {
    animation: scintillate var(--pe, 13s) linear infinite;
    animation-delay: var(--ph, 0s);
  }
  .starmap-drawn .sm-spike {
    animation: spike-flash var(--pe2, 15s) linear infinite;
    animation-delay: var(--ph2, 0s);
  }
}

/* The burst lives in the first ~7% of the cycle; the rest is quiet sky. Uneven
   steps rather than a curve — turbulence is not sinusoidal. */
@keyframes scintillate {
  /* ⛔ THE BURST USED TO SIT AT 0-8% AND THAT COST TEN SECONDS OF STILLNESS.
     A twinkle that opens with a three-peak burst cannot start when a star is
     revealed — it lands on top of the reveal flash and reads as a double
     pulse. The fix had been to PAUSE every twinkle for the whole entrance and
     resume it at teardown, which meant the first star stood still for the
     nine seconds the other nine took to arrive. Reported as "the twinkles
     have a long delay before they start".
     ⭐ So the cycle now OPENS CALM and the burst happens at 24-32%. A star can
     begin twinkling at the instant it appears: it starts at its resting value
     and its first sparkle arrives a few seconds later, on its own period.
     Same burst, same shape, moved off the front. */
  0%    { opacity: .26; transform: scale(.18); }
  24%   { opacity: .26; transform: scale(.18); }
  25.2% { opacity: .88; transform: scale(.26); }
  26.0% { opacity: .38; transform: scale(.19); }
  26.9% { opacity: 1;   transform: scale(.30); }
  27.9% { opacity: .46; transform: scale(.20); }
  29.0% { opacity: .72; transform: scale(.24); }
  30.4% { opacity: .30; transform: scale(.185); }
  32%   { opacity: .26; transform: scale(.18); }
  100%  { opacity: .26; transform: scale(.18); }
}
@keyframes spike-flash {
  0%   { opacity: 0;   transform: scale(.5) rotate(0deg); }
  1.4% { opacity: .55; transform: scale(1) rotate(0deg); }
  3.2% { opacity: .12; transform: scale(.9) rotate(0deg); }
  4.6% { opacity: .34; transform: scale(1.05) rotate(0deg); }
  7%   { opacity: 0;   transform: scale(.6) rotate(0deg); }
  100% { opacity: 0;   transform: scale(.5) rotate(0deg); }
}

/* Engaged: the star holds bright and still. A control being pointed at should
   not also be flickering — that is the one moment the effect works against the
   reader rather than for the composition. */
.starmap-drawn .sm-star:hover .sm-glow,
.starmap-drawn .sm-star:focus-visible .sm-glow,
.starmap-drawn .sm-star.is-active .sm-glow {
  animation-play-state: paused; opacity: .85; transform: scale(.34);
}
.starmap-drawn .sm-star:hover .sm-spike,
.starmap-drawn .sm-star:focus-visible .sm-spike,
.starmap-drawn .sm-star.is-active .sm-spike {
  animation-play-state: paused; opacity: .5; transform: scale(1.1);
}

/* Reduced motion: no flicker, no spike. The core alone carries the position. */
@media (prefers-reduced-motion: reduce) {
  .starmap-drawn .sm-glow { animation: none !important; opacity: .34; transform: scale(.22); }
  .starmap-drawn .sm-spike { animation: none !important; opacity: .22; }
}

/* The local deepening sits under everything drawn and above the photograph. */
.sm-deepen { pointer-events: none; }
/* Slightly brighter cores now they sit on a deepened ground rather than the
   original deep sky — the same read, achieved with light instead of size. */
.starmap-drawn .sm-disc { fill: #FFFFFF; filter: drop-shadow(0 0 2px rgba(190,220,255,.95)); }
.starmap-drawn .sm-glow { opacity: .38; }

/* ══ THE COLOUR CAST, REMOVED ═══════════════════════════════════════════════
   The founder reported the photograph had lost its natural colour. Four things
   were tinting it, and only the first was obvious:

     1. the plate rendered at opacity .85 over a #01030A container — so 15% of a
        BLUE-BLACK showed through the whole image
     2. the vignette was #01030A, reaching 94% at the corners
     3. the local deepening behind Cetus was #01040C
     4. an ffmpeg saturation of 0.96 baked in at encode time

   Every one of them pulled toward blue. The image is now opaque and every wash
   is NEUTRAL black, so darkening changes level without changing hue. The 10%
   the founder asked for comes from brightness(), which cannot cast. */
.starmap { background: #000; }
.starmap-plated .starmap-plate { opacity: 1; filter: brightness(.9); }
.starmap-plated.is-engaged .starmap-plate,
.starmap-plated.is-engaged .starmap-back { opacity: 1; filter: blur(3.5px) brightness(.62); }
.starmap-plated::after {
  background:
    radial-gradient(122% 110% at 50% 48%,
      transparent 32%, rgba(0,0,0,.30) 64%, rgba(0,0,0,.70) 87%, rgba(0,0,0,.90) 100%),
    linear-gradient(180deg, rgba(0,0,0,.42) 0%, transparent 16%, transparent 86%, rgba(0,0,0,.55) 100%);
}

/* ══ STARS THAT LOOK LIKE STARS ═════════════════════════════════════════════
   Two things were making them look drawn rather than photographed:

   THE CROSS. Two straight strokes of uniform width is a plus sign. A real
   diffraction spike is widest at the star and tapers to nothing, so the spikes
   are now four FILLED TRIANGLES with their base at the core — same geometry an
   aperture actually produces, and it reads instantly.

   THE HARD HALO. The glow gradient ran to a stop and ended, leaving a visible
   circular edge. Real bloom falls off continuously and asymptotically, so the
   gradient now has five stops with a long tail to zero. */
.sm-spike { display: none; }        /* the stroked cross is retired */
/* ⛔ THE SPIKE IS WHAT MARKS A NAVIGABLE STAR, AND IT WAS INVISIBLE AT REST.
   Two measurements sit either side of this and both are right: an earlier pass
   found the cores at r:2.6 "read as planets against the photograph's own
   stars" and cut them to 0.85; the audit then measured those same discs at
   2.4px and found the site's entire primary navigation indistinguishable from
   the wallpaper. Size cannot settle it — matching the field hides them,
   exceeding it makes planets.
   ⭐ THE ANSWER IS IN THAT SAME COMMENT: "most of the star read comes from the
   spike, not the brightness". The photograph's stars are points; they have no
   four-point diffraction spikes. A faint PERMANENT spike therefore separates
   the ten from hundreds of similar dots without enlarging anything and without
   drawing the asterism lines the founder ruled out over the plate.
   0.30 at rest; the flare still takes it to full. */
.sm-spike-t {
  fill: #EAF3FF;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
  pointer-events: none;
}
.starmap-drawn .sm-spike-t { opacity: .30; }
/* Reduced motion gets no flare at all, so the resting spike is the only
   affordance those readers have. It is set slightly stronger for that reason. */
@media (prefers-reduced-motion: reduce) { .starmap-drawn .sm-spike-t { opacity: .42; } }
.starmap-drawn .sm-disc { r: 1.25; filter: drop-shadow(0 0 2.5px rgba(210,230,255,.9)); }
.starmap-drawn .sm-star-mira .sm-disc { r: 1.6; }

/* ⛔ AND THE TWINKLE RUNS UNDER REDUCED MOTION. The founder reported no motion
   at all, which is the THIRD time that setting has silenced something on this
   machine — the glows before, the opening page before that. What the preference
   exists to stop is movement: scaling, travel, parallax. A change in brightness
   is not that. So under reduce the scale is pinned and the OPACITY still
   flickers, on the same irregular rhythm. */
@media (prefers-reduced-motion: reduce) {
  .starmap-drawn .sm-glow {
    animation: scintillate-still var(--pe, 13s) linear infinite !important;
    animation-delay: var(--ph, 0s) !important;
    transform: scale(.2) !important;
  }
  .starmap-drawn .sm-spike-t {
    animation: spike-still var(--pe2, 15s) linear infinite !important;
    animation-delay: var(--ph2, 0s) !important;
    transform: none !important;
  }
}
@keyframes scintillate-still {
  0%   { opacity: .24; }  1.2% { opacity: .86; }  2.0% { opacity: .36; }
  2.9% { opacity: 1; }    3.9% { opacity: .44; }  5.0% { opacity: .70; }
  6.4% { opacity: .28; }  8%   { opacity: .24; }  100% { opacity: .24; }
}
@keyframes spike-still {
  0% { opacity: 0; } 1.4% { opacity: .5; } 3.2% { opacity: .1; }
  4.6% { opacity: .3; } 7% { opacity: 0; } 100% { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .starmap-drawn .sm-spike-t {
    animation: spike-flash var(--pe2, 15s) linear infinite;
    animation-delay: var(--ph2, 0s);
  }
}
.starmap-drawn .sm-star:hover .sm-spike-t,
.starmap-drawn .sm-star:focus-visible .sm-spike-t,
.starmap-drawn .sm-star.is-active .sm-spike-t {
  animation-play-state: paused; opacity: .55; transform: scale(1.15);
}


/* ══ THE BACKDROP, RETIRED ══════════════════════════════════════════════════
   Nothing emits .starmap-back any more (build-starmap.js says why). These
   rules are neutralised rather than merely deleted from the sheet above,
   because the sheet is long and a stale selector that quietly starts matching
   again is exactly the class of defect this project keeps finding. */
.starmap-back { display: none !important; }

/* ══ THE VIGNETTE WAS THE COLOUR CAST ═══════════════════════════════════════
   26d4 MEASURED, THERE IS NO CAST — AND THAT MATTERS FOR THE FIX.
   Source PNG against the encoded webp: per-channel ratio 0.963 / 0.984 / 0.969.
   Encoded webp against the rendered page: 0.930 / 0.911 / 0.921. Sampled in
   four regions of the live page against the same regions of the source, the
   spread between channels never exceeds 0.011. No hue is being shifted
   anywhere, so there was nothing tinted left to remove.

   What IS over the photograph is a NEUTRAL veil, and it was far heavier than a
   vignette should be. Measured against the source, the same four regions came
   back at 0.71 of source in the top-left corner and 0.57 along the bottom
   edge — the periphery was losing 29% and 43% of its light while the centre
   lost only the 10% that was asked for. A dark neutral wash flattens colour by
   crushing it toward black, and that is what reads as "something laid over the
   image" even though no channel moved relative to the others.

   So the vignette stays — it was asked for and it holds the eye in frame — but
   at roughly a third of its previous depth, and starting further out. */
.starmap-plated::after {
  background:
    radial-gradient(128% 116% at 50% 48%,
      transparent 46%, rgba(0,0,0,.11) 73%, rgba(0,0,0,.27) 91%, rgba(0,0,0,.38) 100%),
    linear-gradient(180deg, rgba(0,0,0,.19) 0%, transparent 14%, transparent 88%, rgba(0,0,0,.25) 100%);
}

/* ══ THE FLARE ══════════════════════════════════════════════════════════════
   The geometry is in build-starmap.js. What is here is the motion: the rose
   does not merely appear at full size, it FLARES — rays extending and pulling
   back with the brightness, turning a few degrees as they go. A fixed-size
   shape switching opacity is a blinking light; a shape that grows as it
   brightens is a star seen through moving air.

   The rotation is small on purpose. Enough to be felt at the edge of vision,
   not enough to look like a spinning graphic. */
.sm-spike-t { fill: #F4F8FF; }

@media (prefers-reduced-motion: no-preference) {
  .starmap-drawn .sm-spike-t {
    animation: spike-flare var(--pe2, 15s) linear infinite;
    animation-delay: var(--ph2, 0s);
  }
}
@keyframes spike-flare {
  /* Moved off the front for the same reason as scintillate, and to a
     DIFFERENT offset (31%, not 24%) so the two do not fire together and
     re-create the double flash one level down. */
  /* ⚠ .30, NOT 0, AT EITHER END. These frames ARE the resting state whenever
     the animation is running, so a 0 here would cancel the permanent spike
     introduced above and put the stars back in the wallpaper. */
  0%    { opacity: .30; transform: scale(.42) rotate(-5deg); }
  31%   { opacity: .30; transform: scale(.42) rotate(-5deg); }
  32.1% { opacity: .66; transform: scale(1.04) rotate(2deg); }
  33.4% { opacity: .16; transform: scale(.82) rotate(-2deg); }
  34.6% { opacity: .44; transform: scale(1.12) rotate(5deg); }
  36.2% { opacity: .10; transform: scale(.88) rotate(1deg); }
  38%   { opacity: .30; transform: scale(.52) rotate(-3deg); }
  100%  { opacity: .30; transform: scale(.42) rotate(-5deg); }
}
/* Under reduce the rose is pinned at a fixed size and angle and only its
   brightness moves — the preference exists to stop movement, and a star that
   stops twinkling altogether takes the interface with it, since these glows
   are the only thing marking a control on this page. */
@media (prefers-reduced-motion: reduce) {
  .starmap-drawn .sm-spike-t {
    animation: spike-still var(--pe2, 15s) linear infinite !important;
    /* 26d4 THE SHORTHAND RESETS THE DELAY, AND THE DELAY IS THE WHOLE EFFECT.
       Written without this line, `animation:` above set animation-delay back to
       0 and beat the earlier rule that supplied var(--ph2) — so all ten stars
       flared in lockstep on a 15s cycle. Measured: 0 of 40 sampled frames
       showed a ray, because a single synchronised 1s burst inside 15s is
       mostly missed by a 4s sample. Staggered, somewhere on the map a star
       flares about every second and a half. */
    animation-delay: var(--ph2, 0s) !important;
    transform: scale(.92) !important;
  }
}
.starmap-drawn .sm-star:hover .sm-spike-t,
.starmap-drawn .sm-star:focus-visible .sm-spike-t,
.starmap-drawn .sm-star.is-active .sm-spike-t {
  animation-play-state: paused; opacity: .6; transform: scale(1.06) rotate(0deg);
}
.sm-entrance .sm-star:nth-of-type(1) .sm-glow,
.sm-entrance .sm-star:nth-of-type(1) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(1) .sm-spike { animation-delay: calc(var(--sm-cadence) * 1); }
.sm-entrance .sm-star:nth-of-type(2) .sm-glow,
.sm-entrance .sm-star:nth-of-type(2) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(2) .sm-spike { animation-delay: calc(var(--sm-cadence) * 2); }
.sm-entrance .sm-star:nth-of-type(3) .sm-glow,
.sm-entrance .sm-star:nth-of-type(3) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(3) .sm-spike { animation-delay: calc(var(--sm-cadence) * 3); }
.sm-entrance .sm-star:nth-of-type(5) .sm-glow,
.sm-entrance .sm-star:nth-of-type(5) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(5) .sm-spike { animation-delay: calc(var(--sm-cadence) * 4); }
.sm-entrance .sm-star:nth-of-type(6) .sm-glow,
.sm-entrance .sm-star:nth-of-type(6) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(6) .sm-spike { animation-delay: calc(var(--sm-cadence) * 5); }
.sm-entrance .sm-star:nth-of-type(7) .sm-glow,
.sm-entrance .sm-star:nth-of-type(7) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(7) .sm-spike { animation-delay: calc(var(--sm-cadence) * 6); }
.sm-entrance .sm-star:nth-of-type(8) .sm-glow,
.sm-entrance .sm-star:nth-of-type(8) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(8) .sm-spike { animation-delay: calc(var(--sm-cadence) * 7); }
.sm-entrance .sm-star:nth-of-type(9) .sm-glow,
.sm-entrance .sm-star:nth-of-type(9) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(9) .sm-spike { animation-delay: calc(var(--sm-cadence) * 8); }
.sm-entrance .sm-star:nth-of-type(10) .sm-glow,
.sm-entrance .sm-star:nth-of-type(10) .sm-spike-t,
.sm-entrance .sm-star:nth-of-type(10) .sm-spike { animation-delay: calc(var(--sm-cadence) * 9); }

/* ══ THE LANDING HERO ══════════════════════════════════════════
   The message occupies the left of the frame; Cetus sits upper-right, above
   the building. Both were placed from measurement, not by eye — see
   build-landing-plate.js for the constellation box.

   ⛔ THE SCRIM IS NOT DECORATION, IT IS THE ONLY WAY THIS TEXT PASSES.
   Over the region deep enough to hold a headline, a subhead and two buttons,
   the brightest 5% of the photograph measures luminance 183: white text on it
   is 2.0:1 and fails AA outright. At 0.62 alpha the worst case becomes 9.5:1,
   and 8.4:1 against the single brightest pixel in the zone.
   It is a LEFT-ANCHORED gradient, gone by 55% of the width, so the building,
   the two figures and the lit doorway are untouched. */
.hero-sky {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  /* ⚠ THE STARS UNDERNEATH MUST STAY CLICKABLE. The hero covers the whole
     frame so its gradient can reach the edges; only its own links take the
     pointer. Without this the overlay would swallow the navigation it sits on
     top of — which is the defect it exists to fix, inverted. */
  pointer-events: none;
  background: linear-gradient(100deg,
    rgba(6, 8, 12, .78) 0%,
    rgba(6, 8, 12, .62) 26%,
    rgba(6, 8, 12, .34) 42%,
    rgba(6, 8, 12, 0) 55%);
}
.hero-sky-in {
  pointer-events: auto;
  /* ⚠ NOT ch. `ch` resolves against THIS element's font size, not the
     headline's, so 46ch computed to 460px and set the h1 over five lines with
     "first-grade" split across two of them. A length that does not depend on
     which child is being measured is the right unit here. */
  width: clamp(20rem, 46vw, 44rem);
  margin-inline-start: clamp(1.5rem, 6vw, 6.5rem);
  margin-block-end: 6vh;
}
.hero-h {
  margin: 0;
  font-size: clamp(2rem, 3.9vw, 3.5rem);
  line-height: 1.06;
  letter-spacing: -0.02em;
  font-weight: 400;
  color: #FFFFFF;
  text-wrap: balance;
}
.hero-cat {
  margin: 1.05rem 0 0;
  font-size: clamp(1.06rem, 1.5vw, 1.3rem);
  line-height: 1.4;
  /* wider than .hero-sub's 42ch on purpose: this line carries the five
     program names and reads as a list, not as prose. */
  max-width: 54ch;
  color: #FFFFFF;
  text-wrap: balance;
}
.hero-sub {
  margin: 1.1rem 0 0;
  font-size: clamp(1rem, 1.25vw, 1.16rem);
  line-height: 1.5;
  max-width: 42ch;
  color: rgba(255, 255, 255, .90);
}
/* THE HERO SLOT - one region, three paragraphs, and later the star overlays.
   min-height, NOT a fixed height and NOT auto:
     auto  -> the CTA buttons jump every time the content swaps, which is the
              one thing this design exists to prevent;
     fixed -> clips the first time a sentence gets longer;
     min-  -> the floor holds the buttons still, and longer copy grows the page
              instead of being cut off. Fails safe in both directions.
   The floor is set from the TALLEST occupant. Measured 2026-08-18: the middle
   paragraph is 242 characters and every one of the ten star texts is 92-135, so
   the paragraphs set the height and the star content fits inside it with room
   to spare - unifying the overlays into this slot costs no extra height. */
.hero-slot {
  position: relative;
  margin: 1.1rem 0 0;
  /* MEASURED FLOORS, not guessed. The tallest SINGLE paragraph, per band, from
     _measure at 1440/1024/900/768/460/390/360: 135px / 120px / 120px / 120px /
     120px / 144px / 168px. Each floor sits just above its band's tallest, so a
     one-paragraph state never lets the buttons rise and never leaves a chasm. */
  min-height: 10rem;
}
.hero-p { margin: 0; }
/* THE STACK IS THE FALLBACK, and it is the DEFAULT rather than an override:
   with no scroll-driven animation support, and under reduced motion, all three
   paragraphs are simply visible in document order. Nothing is hidden here. */
.hero-slot .hero-p + .hero-p { margin-top: .55rem; }
.hero-say {
  font-size: clamp(1rem, 1.25vw, 1.16rem);
  line-height: 1.5;
  max-width: 46ch;
  color: rgba(255, 255, 255, .90);
}
@media (max-width: 900px) { .hero-slot { min-height: 9.5rem; } }
@media (max-width: 400px) { .hero-slot { min-height: 11.5rem; } }

.hero-line {
  margin: 1rem 0 0;
  font-size: clamp(.98rem, 1.2vw, 1.1rem);
  color: rgba(255,255,255,.95);
  border-left: 2px solid rgba(255,255,255,.34);
  padding-left: .8rem;
}
.hero-do { display: flex; flex-wrap: wrap; gap: .7rem; margin: 1.6rem 0 0; }
.hero-nav { margin: 1.1rem 0 0; color: rgba(255, 255, 255, .72); }
.hero-nav-hint { border-bottom: 1px dotted rgba(255, 255, 255, .4); }

/* ⚠ BELOW ~900px THE FRAME HAS NO DARK LEFT HALF TO SPEAK OF — the stage is
   sized to cover, so a narrow window crops to the middle of the photograph
   where the dawn glow sits. The gradient becomes a full-width wash there
   rather than a left-anchored one, and the measured worst case holds. */
@media (max-width: 900px) {
  .hero-sky {
    align-items: flex-end;
    background: linear-gradient(180deg,
      rgba(6, 8, 12, .30) 0%,
      rgba(6, 8, 12, .62) 42%,
      rgba(6, 8, 12, .86) 100%);
  }
  .hero-sky-in { width: auto; margin-inline: 1.25rem; margin-block-end: 8vh; }
  .hero-h { font-size: clamp(1.75rem, 7vw, 2.4rem); }
}

/* ══ BELOW THE FOLD ═════════════════════════════════════════ */
.lp-proof {
  display: flex; flex-wrap: wrap; gap: clamp(1.5rem, 5vw, 4rem);
  margin: 1.4rem 0 0;
}
/* ⚠ A CAPTION SET FOUR LINES DEEP IN A NARROW COLUMN is the audit's S9,
   reproduced. The flex row put the number and its caption side by side inside
   30ch, so the caption got whatever was left — about 14 characters — and
   "run the same student information system" set over four lines. Stacking
   them gives the caption the full column. */
.lp-stat { margin: 0; display: block; max-width: 26ch; flex: 1 1 20ch; }
.lp-stat b { display: block; margin-bottom: .25rem;
  font-size: clamp(2.2rem, 4.5vw, 3.4rem); font-weight: 400; line-height: 1;
  letter-spacing: -0.02em; color: var(--star); font-variant-numeric: tabular-nums;
}
.lp-stat span { color: var(--star-dim); line-height: 1.35; }
.lp-src { margin: 1.1rem 0 0; max-width: 70ch; color: var(--star-dim); }
.lp-honest { margin: 1.4rem 0 0; max-width: 66ch; color: var(--star); }

/* ══ the citation ledger ═════════════════════════════════════
   A count column that has to align, five rows, and a definition beside each.
   ⚠ SUBGRID RATHER THAN A FLEX ROW PER ITEM: the numbers run 2 to 66, so a
   per-row flex would set each <b> to its own width and the column would step.
   One grid across all rows gives every count the same track, which is the only
   reason the figures read as a ledger instead of five unrelated statistics. */
.lp-ledg {
  list-style: none; margin: 1.5rem 0 0; padding: 0;
  display: grid; grid-template-columns: auto 1fr; gap: 0;
  max-width: 62ch;
}
.lp-ledg li {
  display: grid; grid-column: 1 / -1; grid-template-columns: subgrid;
  align-items: baseline; gap: 0 1.1rem; padding: .7rem 0;
  border-bottom: var(--hair) solid color-mix(in srgb, var(--star) 14%, transparent);
}
.lp-ledg li:first-child { border-top: var(--hair) solid color-mix(in srgb, var(--star) 14%, transparent); }
.lp-ledg b {
  justify-self: end; font-weight: 400; font-size: 1.5rem; line-height: 1;
  color: var(--star); font-variant-numeric: tabular-nums; letter-spacing: -0.01em;
}
.lp-ledg span { color: var(--star-dim); line-height: 1.4; }
/* The status word is the registry's own token, so it is set as a term rather
   than folded into the sentence — a reader comparing this to a badge in the
   software should see the same word in the same shape. */
.lp-ledg em {
  display: block; font-style: normal; color: var(--star);
  font-size: .78rem; letter-spacing: .1em; text-transform: uppercase;
  margin-bottom: .15rem;
}
@supports not (grid-template-columns: subgrid) {
  .lp-ledg li { grid-template-columns: 3.5ch 1fr; }
}

.lp-progs { list-style: none; margin: 1.4rem 0 0; padding: 0; display: grid; gap: .1rem; }
.lp-progs li {
  display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap;
  padding: .6rem 0;
  border-bottom: var(--hair) solid color-mix(in srgb, var(--star) 14%, transparent);
}
.lp-progs a { font-size: 1.06rem; color: var(--star); }
.lp-progs span { color: var(--star-dim); font-size: var(--m-tick); }

/* THE HERO PIN - LAST IN THE FILE ON PURPOSE, because .starmap{position:relative}
   appears above and would win on source order at equal specificity. */
.starmap-track { position: relative; }
/* NARROW GETS THE SAME BEHAVIOUR. Scoping this to >=1024px left portrait with
   BOTH defects at once: all three paragraphs stacked, and the old floating card
   laying an image over that text. The width guard is gone; only the TRACK
   LENGTH differs, because a phone viewport is the thing worth being frugal
   with, not the behaviour. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .starmap-track { height: 300vh; /* view-timeline, NOT scroll-timeline: scroll-timeline needs the element to BE a
       scroll container, and the track is not one - it is an element the document
       scrolls past. view-timeline measures exactly that progress. */
    view-timeline-name: --heroTrack; view-timeline-axis: block; }
    .starmap { position: sticky; top: 0; }
    /* :not(.hero-star) - the star paragraph carries .hero-p for layout but must
       NOT take the scroll animation, or it inherits a 0%-opacity fill and never
       shows. */
    .hero-slot .hero-p:not(.hero-star) {
      position: absolute; inset: 0 auto auto 0; width: 100%;
      animation-name: heroSlotSwap; animation-fill-mode: both;
      animation-timing-function: linear; animation-duration: auto;
      animation-timeline: --heroTrack; animation-range: contain 0% contain 100%;
    }
    .hero-slot .hero-p + .hero-p { margin-top: 0; }
    /* BEFORE THE TIMELINE IS ACTIVE the animations do not apply at all, so at
       rest every paragraph fell back to visible - three absolutely-positioned
       paragraphs stacked on one another. The base state inside the pinned block
       shows the first only; the animation overrides it once scrolling starts. */
    /* specificity matched to the animation rule above (:not() counts), or the
       blanket animation-range on that rule wins over the per-slot ranges and
       every paragraph animates across the whole track at once. */
    .hero-slot .hero-p[data-slot="2"], .hero-slot .hero-p[data-slot="3"] { opacity: 0; visibility: hidden; }
    .hero-slot .hero-p[data-slot="1"] { animation-name: heroSlotFirst; animation-range: contain 0% contain 32%; }
    .hero-slot .hero-p[data-slot="2"] { animation-range: contain 34% contain 62%; }
    /* 200vh rather than 300vh below 1024: three states still get two thirds of
       a viewport each, and the reader reaches the content sooner. */
    @media (max-width: 1023px) { .starmap-track { height: 200vh; } }
    .hero-slot .hero-p[data-slot="3"] { animation-name: heroSlotLast; animation-range: contain 64% contain 100%; }
  }
}
/* visibility as well as opacity: opacity alone leaves a retired paragraph in the
   accessibility tree and reachable by Ctrl+F while invisible. */
@keyframes heroSlotSwap {
  0%   { opacity: 0; visibility: hidden; transform: translateY(6px); }
  14%  { opacity: 1; visibility: visible; transform: none; }
  86%  { opacity: 1; visibility: visible; transform: none; }
  100% { opacity: 0; visibility: hidden; transform: translateY(-6px); }
}
/* THE FIRST AND LAST PARAGRAPHS HOLD AT THEIR EDGES. With the shared keyframes
   the slot was EMPTY at rest and empty again past the third paragraph - the
   reader landed on a blank space and left on one. The first is opaque from 0%,
   the last stays opaque to 100%; only the middle fades at both ends. */
@keyframes heroSlotFirst {
  0%   { opacity: 1; visibility: visible; transform: none; }
  84%  { opacity: 1; visibility: visible; transform: none; }
  100% { opacity: 0; visibility: hidden; transform: translateY(-6px); }
}
@keyframes heroSlotLast {
  0%   { opacity: 0; visibility: hidden; transform: translateY(6px); }
  16%  { opacity: 1; visibility: visible; transform: none; }
  100% { opacity: 1; visibility: visible; transform: none; }
}

/* THE STAR TEXT SHARES THE PARAGRAPH SLOT. One region, one motion vocabulary,
   and no absolutely-positioned card left to collide with other text - which was
   the defect that prompted unifying them. */
.hero-star {
  opacity: 0; visibility: hidden;
  font-size: clamp(1rem, 1.25vw, 1.16rem); line-height: 1.5;
  max-width: 46ch; color: rgba(255, 255, 255, .92);
}
/* !important because a running CSS animation outranks a normal author
   declaration; without it the engaged state loses to the swap keyframes. */
.hero-slot.is-star .hero-p:not(.hero-star) { opacity: 0 !important; visibility: hidden !important; }
.hero-slot.is-star .hero-star { opacity: 1; visibility: visible; }
/* the card is suppressed only where the slot exists to replace it */
/* AT EVERY WIDTH. The slot exists on narrow too, so the floating card has
   nothing left to do there except cover the text it used to sit beside. */
.is-engaged .starmap-card { display: none; }

/* ONE MEASURE FOR THE WHOLE SLOT. Measured 2026-08-18: the column is 662px and
   only the category line used it - the sub sat at 462 and the star at 506, so
   the space read wider than anything in it needed. A single cap on the slot
   makes the four states share one measure instead of three ragged ones. */
.hero-slot { max-width: 33rem; }
.hero-slot .hero-p { max-width: none; }
/* the go-cue: this is a place you can GO, not a fourth paragraph */
.hero-star-go {
  display: block; margin-top: .7rem;
  color: var(--waterline); font-size: var(--m-tick); letter-spacing: .05em;
}

/* ── RETIRING THE ENGAGE DIM/BLUR (founder 2026-08-18) ───────────────────────
   Debt from the floating-card styling: the plate was blurred and darkened and
   the other stars dimmed so a card sitting ON the field could be read. The text
   now lands in the hero slot, which is off the field entirely, so there is
   nothing to recede behind and the effects only cost legibility.
   Overridden here rather than deleted at their three original sites, because
   those rules carry the plated/drawn variants and unpicking them by hand is how
   a visual regression arrives in a file nobody re-renders. */
.starmap-plated.is-engaged .starmap-plate,
.starmap-plated.is-engaged .starmap-back { filter: none; opacity: 1; }
/* the other stars keep their glow: the ACTIVE one gets brighter, the rest are
   not punished for it */
.starmap.is-engaged .sm-star:not(.is-active) .sm-glow { opacity: 1; }

/* THE ACTIVE STAR CARRIES THE WHOLE SIGNAL NOW. Measured before this: on hover
   the active glow sat at 0.26 - identical to every resting star - so with the
   zoom and the backdrop blur gone there was nothing left to say which star you
   were on. It goes solid and larger instead, which is the signal the founder
   asked for and the only one the design still needs. */
/* !important for the same reason the hero slot needed it: the glow carries an
   animation (paused when active), and a running-or-paused animation outranks a
   normal author declaration. Second time this cascade rule has bitten today. */
.starmap .sm-star.is-active .sm-glow { opacity: 1 !important; }
.starmap-drawn .sm-star.is-active .sm-disc { fill: #FFFFFF; r: 4.8; }
.sm-star .sm-disc { transition: r 180ms ease-out; }

/* NARROW CENTRES THE HERO BLOCK. It was align-items:flex-end plus an 8vh bottom
   margin, so every pixel of slack piled up ABOVE the text and the imbalance grew
   with viewport height - measured gap above/below was 192/169 at 390w, 368/186
   at 430w and 579/82 at 768w. Bottom-anchoring made sense when the hero was a
   caption under a constellation; it now carries the whole proposition and should
   sit in the optical middle. The bottom margin drops with it, or centring just
   re-introduces the same offset upside down. */
@media (max-width: 1023px) {
  .hero-sky { align-items: center; }
  .hero-sky-in { margin-block-end: 2vh; }
}
