/* Registered, and NOT inherited: the scroll zoom is read by the two
   .sky-stage elements and nothing under them, and as an inherited property on
   the section every write restyled all 747 links. sky.js writes it on the two
   stages themselves. Browsers without @property inherit it as before, and
   still get it from the same two writes. */
@property --sky-zoom{syntax:'<number>';inherits:false;initial-value:1}
/* ==========================================================================
   The Treatment Tapes — the constellation sky
   Loaded only on pages that carry one (the home page and /sky/*).

   Consumes tapes-tokens.css like everything else. The only raw colours here
   are inside SVG paint and inside rgba() ramps, where a token cannot reach.

   Layers, back to front:
     0  .sky-field    <canvas>  nebular haze, Milky Way, ~900 stars in three
                                parallax layers, shooting stars. Decoration,
                                JS-only, aria-hidden.
     1  .sky-map      <svg>     the ~90 topic stars and the lines between them.
                                Server-rendered: every one is a real link with
                                a real accessible name before any script runs.
     2  .sky-hud                the upcoming-star note and the group filter.
     2  .sky-hud                the legend, the plate caption and the note —
                             all of the map's marginalia, in one corner.
     3  .sky-tip / .sky-card    identify on hover; the card for an upcoming
                                topic, which has no page of its own.

   ORDERING RULE, learned the hard way twice: this sheet is written as
   RESTING APPEARANCE first, INTERACTION second, LAYOUT OVERRIDES last. Several
   rules here are the same specificity as the ones they must beat
   (.nd[data-status="upcoming"] .nd-dot and .nd:hover .nd-dot are both (0,3,0);
   .sky-stage and .sky-plot are both (0,1,0) and land on the same element), so
   source order is the only thing deciding them. A rule added to the wrong
   group fails silently and looks like nothing happened.
   ========================================================================== */

/* ---------------------------------------------------------------- the stage */
.sky{
  position:relative;
  /* Two depth cues, both BEHIND the star canvas because they deepen the
     ground rather than the stars: overhead should be the darkest part of a
     sky and also the part with the brightest stars in it, so darkening the
     top in front of the field would have got both backwards.
       1. the top sixth, deepened — the sky is further away overhead;
       2. a bottom-heavy atmospheric haze at about 4%, centred below the
          frame, which is where the air is thickest. */
  /* ---- the deep field ---------------------------------------------------
     A real Milky Way photograph is never one colour. It runs warm and slightly
     brighter at the horizon, where you are looking through the most air, and
     goes almost black at the zenith, where you are looking through the least.
     Four composites, all static, all BEHIND the star canvas, because they
     grade the ground rather than the stars:

       1. the tonal ramp, coldest at the top;
       2. a warm radial on the galactic core, so the band emits;
       3. a cool limb from the upper left, as if lit from out of frame;
       4. sodium airglow along the bottom.

     Layered CSS gradients with background-blend-mode rather than the SVG
     feBlend the brief describes. Same compositing, same static cost, and it
     stays in the stylesheet with every other colour decision instead of
     becoming markup — an SVG filter chain here would also force the whole
     section onto a filtered raster layer, which is exactly what §4's frame
     budget cannot afford.

     The last stop is --ink, not the #0A0F1C the brief asks for at the bottom
     edge. That edge is the seam into the cassette hero, which IS --ink, and a
     seven-level step there would undo the one thing four passes have
     protected. The grading reaches its horizon tone just above the seam and
     hands off. Measured after: worst step unchanged. */
  background:
    linear-gradient(to top,
      rgba(10,15,28,0)    0%,
      rgba(10,15,28,.55)  10%,
      rgba(8,11,24,.72)   30%,
      rgba(5,8,21,.84)    60%,
      rgba(3,5,15,.93)    92%,
      rgba(2,3,11,.96)    100%),
    radial-gradient(1400px 820px at 18% -8%, rgba(30,45,75,.14), transparent 60%),
    var(--ink);
  background-blend-mode:normal, screen, normal;
  overflow:hidden;
  isolation:isolate;
  /* Fills the viewport minus the chrome stacked above it, so the first screen
     is sky and not sky-plus-a-gap. 100svh, not 100vh: on iOS 100vh is the
     tallest the viewport ever gets, which leaves the caption under the URL bar.

     The floor matters on a short viewport (a small phone in landscape): without
     it the inset plot box inverts and the map spills over the caption. */
  min-height:max(100svh, calc(var(--sky-chrome-h) + var(--sky-plot-b) + 13rem));
  display:flex;flex-direction:column;justify-content:flex-end;

  /* Pulled up behind the notice, the header and the meta strip and given
     matching top padding, so the first screen is sky with the masthead
     floating on it rather than sky starting below a band of paper.

     --sky-chrome-h is a measured constant, not a JavaScript measurement, and
     that is deliberate: setting it at runtime would move every section below
     the fold after first paint, which is exactly what Cumulative Layout Shift
     counts. Being a few pixels out only changes how tall the sky is, which
     nobody can see. Re-measure if the notice or the strip gains a line. */
  margin-top:calc(-1 * var(--sky-chrome-h));
  padding-top:var(--sky-chrome-h);
}
.has-sky{--sky-chrome-h:9.25rem}                                   /* 148px */
@media (max-width:640px){.has-sky{--sky-chrome-h:11.75rem}}        /* 188px — notice and strip both wrap */
/* "Phone" here is a height condition as well as a width one. A phone held
   sideways is 844 x 390: wide enough to miss a width-only breakpoint, but with
   a band so short that the wide field is height-limited and its ninety stars
   end up 17px apart — measured, and under the 24px target-spacing floor. The
   phone composition is the right answer at that shape too. */
/* Furniture declared immediately before the media query that adjusts it.
   Order matters and has already bitten twice here: two rules of equal
   specificity are settled by which comes last, so a default placed after the
   query silently wins and the thing never appears. */
/* The drag hint runs at EVERY width now, because the gesture does: the phone
   turns the sphere and the desktop pans the window across the reserve either
   side of the composition (SKY_PAN in build.py). It used to be display:none
   here and switched on only inside the phone query, from when turning the sky
   was a phone-only affordance. It retires itself the first time the sky is
   moved (see .is-turned). */
.sky-turn{
  display:block;margin:0;padding:0 0 .25rem;
  font-family:var(--display-grotesk);font-size:.6875rem;letter-spacing:.04em;
  text-transform:uppercase;color:rgba(228,236,246,.42);
  transition:opacity 600ms var(--ease);
}
.sky.is-turned .sky-turn{opacity:0}
/* Stacked under the caption's first line on a pointer device; the media query
   below switches it off for the phone, which cannot hover. This MUST stay above
   that query: it and the query's rule have equal specificity, so whichever
   comes last wins. Declared after it, "hover a star to identify" reappeared on
   a phone -- the very failure the note above describes. */
.sky-cap-wide{display:block}

@media (max-width:767.98px), (max-height:560px){
  /* A shorter band on a phone. Still the first thing, still interactive,
     but it does not cost the whole screen before any words appear. The floor
     is what keeps the map from being squeezed to nothing once the masthead,
     the caption and the HUD have all taken their share. */
  /* The phone field is a SQUARE now — a celestial sphere seen through a round
     aperture, 1000 x 1000 — so the plot has to be at least as tall as it is
     wide or the sphere letterboxes and every tap target shrinks with it.
     The field is 1000 x 1400 now, so the plot must be 1.4x its own width --
     min(140vw, 36rem). On-screen scale stays WIDTH-limited either way, which
     is the whole reason the taller field was free: it bought sphere capacity
     without shrinking a single star.

     The arithmetic that matters: the closest two stars anywhere on the sphere
     are 77.7 units apart in a 1000-unit field, so the on-screen gap is
     0.0777 x the aperture. At 320px — the narrowest phone worth counting —
     that is 24.9px, just over WCAG 2.5.8's 24px floor. Shrink the aperture
     below the viewport width and that number goes under it. */
  .sky{min-height:max(min(34rem, 76svh),
                      calc(var(--sky-chrome-h) + var(--sky-plot-b)
                           + min(140vw, 36rem)))}
  /* The Greek letters do not come to the phone. They are sized in USER units
     so they scale with the map, and the phone's aperture puts 1000 units into
     390px — the letter lands at 4.7px, which is not a letter, it is a smudge.
     They would also be the only thing inside the sphere that has to be
     counter-rotated as it turns, and counter-rotating them costs a custom
     property write per frame that invalidates style for every element under
     the map. Not drawing an illegible glyph is the cheaper and the better
     answer; the desktop chart keeps them. */
  .nd-greek{display:none}
  /* The hint and its retirement are declared above the query now -- it is no
     longer phone-only furniture. What IS phone-only is the reserve: the
     sphere already supplies capacity through the turn, and the reserve's
     stars carry wide-composition coordinates that would land anywhere. */
  .sky-reserve{display:none}
  .sky-cap-wide{display:none}
}

/* The masthead paints above the sky despite coming first in the document. */
.has-sky .notice,.has-sky .strip{position:relative;z-index:90}

/* A real skip link. The page's own "Skip to content" lands on <main>, which is
   ABOVE the sky, so it does not help a keyboard visitor get past ninety stars.
   Escape inside the map focuses this. */
.sky-skip{
  /* Out of the layout entirely until focused, not merely nudged upward: a
     percentage translate only moves the element by its OWN height, which left
     it sitting in plain sight across the masthead. */
  position:absolute;width:1px;height:1px;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap;
  top:calc(var(--sky-chrome-h) + 10px);left:50%;z-index:95;
}
.sky-skip:focus-visible{
  width:auto;height:auto;overflow:visible;clip-path:none;
  transform:translateX(-50%);
  background:var(--paper);color:var(--ink);
  font-family:var(--label);font-size:var(--t-label);font-weight:550;
  letter-spacing:.14em;text-transform:uppercase;
  padding:9px 16px;border-radius:var(--r-sm);
  box-shadow:0 8px 24px rgba(0,0,0,.42);
}

.sky-stage{
  position:absolute;inset:0;z-index:0;
  transform:scale(var(--sky-zoom,1));transform-origin:50% 44%;
  will-change:transform;
}

/* ---------------------------------------------------------- the GL canvas
   ONE element, covering the section, holding the entire sky: the band, the
   nebulae, the three parallax planes, the reserve field, the constellation
   figures, seven hundred and forty-seven topic stars and their labels.

   NO transform, NO will-change, NO filter, NO blend mode, and there never
   will be one. Everything that used to be a CSS transform on a rasterised
   tree -- the pan, the drift, the scroll-linked zoom -- happens inside the
   canvas now, as a matrix. The compositor's entire job here is to blend one
   static surface, which is a job it has never once got wrong.

   That is the whole architectural claim, and it is why the seam cannot come
   back: there is no second surface to arrive late against, no tile to be
   unfilled, no backdrop to be re-read. A rectangle, drawn from vertices,
   every frame.

   It sits UNDER the moon and the plot in z-order for the same reason the 2D
   field did (see the note by .sky-moon in build.py) -- while the SVG is
   still present as the fallback, both compositions have to stack the way
   they always did. */
.sky-gl{
  position:absolute;inset:0;z-index:0;
  display:block;width:100%;height:100%;
  pointer-events:none;
}
/* With the GL renderer live the SVG is visibility:hidden rather than gone:
   it keeps its layout, and so getScreenCTM, which is what every coordinate
   in sky.js still asks of it. Belt and braces on top of the inline style
   sky.js sets, so a stylesheet that loads late cannot bring it back. */
/* The map's picture is GL's; its LINKS are still the document's. Hidden
   wholesale, the SVG took all 747 of them out of the accessibility tree and
   the focus order, which is what the keyboard map, search's choose and every
   screen reader walk. So the SVG stays out of paint and the links come back:
   each holds one transparent hit circle and paints nothing. */
.sky.is-gl .sky-map{visibility:hidden}
.sky.is-gl .sky-map .nd{visibility:visible}
.sky.is-gl .sky-field{display:none}
/* Inset NEGATIVELY by the field's own pad, so the canvas overhangs the
   section on every side and .sky{overflow:hidden} crops it. That overhang is
   what the celestial drift rotates into: the rotation is a transform on this
   element, which the compositor does for free, rather than a ctx.rotate()
   around three full-size bitmaps on every frame. transform-origin is set in
   JS, in px, because it has to land on the virtual pole. */
.sky-field{
  position:absolute;inset:calc(-1 * var(--sky-over, 0px));
  /* A canvas is a replaced element: with width and height auto an absolutely
     positioned one takes its INTRINSIC size, which is its backing store --
     the CSS size times the device pixel ratio -- and opposing insets do not
     stretch it. So on every 2x and 3x screen the field was drawn at twice its
     size until GL took over, a magnified sky on every phone visit. */
  width:calc(100% + 2 * var(--sky-over, 0px));
  height:calc(100% + 2 * var(--sky-over, 0px));
  display:block;
  transform:rotate(var(--sky-rot, 0deg));
  will-change:transform;
}

/* The map is inset to the band the chrome leaves it: a topic star under the
   masthead would be unreachable, because the masthead sits at z-index 90 and
   swallows the pointer across its whole width. The deep field behind is NOT
   inset — it is texture and runs edge to edge; cropping it would draw a seam.

   MUST stay below .sky-stage: both are (0,1,0) and both land on this element,
   so moving it up silently restores inset:0. */
.sky-plot{
  top:var(--sky-chrome-h);bottom:var(--sky-plot-b);
  /* pan-y, not none: the sky takes the horizontal component -- the phone turns
     the sphere, the wide map travels across the reserve -- and the page keeps
     the vertical and scrolls. Trapping the page inside a map this tall would
     cost more than the gesture is worth.

     This used to sit inside the phone query, from when only the phone had a
     gesture. A touch device in the WIDE layout (a tablet, a laptop with a
     touchscreen) then kept the browser default, and the browser could claim
     the horizontal component before the pan ever saw it.
     pinch-zoom too: a two-finger pinch over the map is the visitor zooming
     the PAGE, and pan-y alone forbade it over 70% of the first screen. */
  touch-action:pan-y pinch-zoom;
  /* A press on the map is a drag or a tap, never a text selection or iOS's
     link preview, and never a grey flash over the whole plot. */
  -webkit-user-select:none;user-select:none;
  -webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;
  /* The rotation is the celestial drift, on the same pole as the canvas field
     (see DRIFT_RATE in sky.js). The topic stars belong to the sky, so they
     turn with it; if they did not, the constellations would slide off the
     field over a few minutes of reading. */
  transform-origin:var(--sky-rot-x,140%) var(--sky-rot-y,-40%);
  transform:translate3d(var(--sky-px,0px), var(--sky-py,0px), 0)
            rotate(var(--sky-rot,0deg)) scale(var(--sky-zoom,1));
}
/* ...but a label does not tip over. Counter-rotated about its own anchor, in
   CSS, so it costs nothing per frame. */
.nd-greek{
  transform:rotate(calc(-1 * var(--sky-rot, 0deg)));
  transform-box:fill-box;transform-origin:0 50%;
}
/* Nothing sits under the map any more — the caption and the note moved up
   into the HUD with the legend — so this only has to keep the lowest stars off
   the gradient that dissolves the sky into the section below. */
.has-sky{--sky-plot-b:3rem}
@media (max-width:767.98px), (max-height:560px){.has-sky{--sky-plot-b:2.25rem}}

/* A faint lift at the horizon so the band reads as sky rather than a dark
   rectangle, and so the caption has something to sit against. The nebular
   haze on the canvas does the rest, and only when JavaScript has run. */
.sky::before{
  content:"";position:absolute;inset:0;z-index:0;pointer-events:none;
  background:
    radial-gradient(120% 82% at 50% 118%, rgba(198,223,242,.11), transparent 62%),
    radial-gradient(90% 60% at 18% -8%, rgba(28,53,87,.50), transparent 70%);
}
/* The horizon: a 120px band of distant atmosphere before the hero begins.
   It rises to 18% and comes back to ZERO at the very bottom edge — a glow
   still live at the boundary would put a five-level step across the seam that
   the whole of .sky::after exists to prevent. Measured: worst step 1-2 levels
   with this, 5 without the return to zero. */
.sky::before{
  /* Two horizon layers, both returning to ZERO at the very bottom edge. A
     glow still live at the boundary puts a step across the seam that the whole
     of .sky::after exists to prevent — measured at five levels the first time
     this was tried without the return to zero.

       - the atmospheric lift, 120px, cool;
       - airglow, warm-green, peaking at 12% of the sky's height (v4.5: was
         8% at .09, which was too faint to read as anything);
       - zenith darkening, 12% down from the TOP.

     Airglow is the recognisable one: every long-exposure horizon shot has
     that warm band, and it is the cheapest "photographed, not rendered" cue
     there is. v4.5 shifts it from pure sodium toward green -- real airglow is
     sodium AND excited oxygen at 557nm, and the green is most of why a long
     exposure of a "black" sky comes back faintly luminous.

     Zenith darkening is the same physics read the other way: overhead you
     look through the least atmosphere, so there is the least of everything --
     the least scatter, the least glow, the least light. The top of a real sky
     is always its darkest part, and a rectangle that is equally dark at the
     top and the middle reads as paint. */
  /* The first two layers are the horizon, and they are why the old seam had
     a lit band sitting on it. Airglow peaked at .14 only 108px above the
     boundary and the cool lift at .18 only 46px above it -- both inside the
     zone the fade then crushes to ink -- so the sky got brighter right up to
     the edge and then fell off it. Ablating the layer flattened that band
     completely (33.6 -> 28.3 at 100px up), so it was the band.

     Same two hues, same job, same return to zero at the bottom edge. Each is
     now about half the strength, peaks two or three times higher, and rises
     and falls along a smooth curve rather than three straight segments: the
     warmth reads as air over the whole lower sky, among the stars, rather
     than as a lit strip lying along the ground. */
  background-image:
    linear-gradient(to top,
      rgba(85,75,45,0)    0px, rgba(85,75,45,.012) 45px,
      rgba(85,75,45,.035) 95px, rgba(85,75,45,.062) 145px,
      rgba(85,75,45,.078) 195px, rgba(85,75,45,.072) 245px,
      rgba(85,75,45,.048) 300px, rgba(85,75,45,.020) 355px,
      rgba(85,75,45,0)  410px),
    linear-gradient(to top,
      rgba(46,58,88,0)    0px, rgba(46,58,88,.015) 35px,
      rgba(46,58,88,.045) 80px, rgba(46,58,88,.075) 130px,
      rgba(46,58,88,.085) 175px, rgba(46,58,88,.060) 230px,
      rgba(46,58,88,.025) 285px, rgba(46,58,88,0) 340px),
    linear-gradient(to bottom,
      rgba(0,1,5,.42) 0px, rgba(0,1,5,.20) 52px, rgba(0,1,5,0) 108px),
    radial-gradient(120% 82% at 50% 118%, rgba(198,223,242,.11), transparent 62%),
    radial-gradient(90% 60% at 18% -8%, rgba(28,53,87,.50), transparent 70%);
  background-repeat:no-repeat;
  /* The zenith layer is offset by --sky-chrome-h. The section runs UP
     BEHIND the masthead, so a gradient positioned at `top` paints its
     whole 108px under opaque chrome and changes nothing you can see --
     measured at 4.70 levels against 4.72 without it. */
  background-position:bottom, bottom, 0 var(--sky-chrome-h, 0px), center, center;
  background-size:100% 410px, 100% 340px, 100% 108px, auto, auto;
  background-blend-mode:screen, normal, normal, normal, normal;
}

/* The sky does not stop, it settles. What follows on the home page is the film
   hero, painted the same --ink, so a hairline rule here read as a scratch
   across one continuous dark field. This dissolves the star field into the
   ground — and the hero carries its own faint stars on top (.sky-veil), so the
   two sections read as one night rather than two dark panels. */
/* THE FALL INTO THE GROUND. Rebuilt with the seam in mind, and every other
   layer below this comment that touches the bottom edge was rebuilt with it.

   The version before read as a section break, and measuring it said exactly
   why. Across the boundary, on the side of the frame the cassette sits in,
   median luminance went 33.6 at 120px above the seam, 26.7 AT the seam and
   47.2 at 80px below it: a lighter band, then the darkest line in the
   region, then another lighter band. That shape -- a dark line with a bright
   shoulder either side, each ramp ending in a kink -- is the one the eye
   reads as an edge, and it was sitting exactly on the boundary.

   So the fall is now one continuous curve, sampled from a smooth function
   rather than four straight segments: next to nothing for its first half,
   half-way at about 95px, and landing on ink with zero slope so there is no
   kink for the eye to catch. It is also LIGHTER than the old one everywhere
   above the last 30px, which is what lets the stars thin out rather than
   stop: at 150px up the old fade had already taken 45% of every star, this
   one takes 26%. The lower sky can afford that only because the horizon
   glows above no longer pile up at the edge (see .sky::before).

   It lands on --ink because the section below starts on --ink: the two must
   agree in value AND in slope at the boundary, and they now do. */
.sky::after{
  content:"";position:absolute;left:0;right:0;bottom:0;z-index:1;
  height:clamp(12rem, 44%, 26rem);pointer-events:none;
  background:linear-gradient(to bottom,
    rgba(17,28,46,0)     0%,
    rgba(17,28,46,.010) 12.5%,
    rgba(17,28,46,.035) 25%,
    rgba(17,28,46,.075) 37.5%,
    rgba(17,28,46,.140) 50%,
    rgba(17,28,46,.220) 60%,
    rgba(17,28,46,.330) 68.75%,
    rgba(17,28,46,.460) 76.25%,
    rgba(17,28,46,.600) 82.5%,
    rgba(17,28,46,.740) 88%,
    rgba(17,28,46,.860) 92.5%,
    rgba(17,28,46,.950) 96.25%,
    rgba(17,28,46,.985) 98.5%,
    var(--ink)          100%);
}

/* ------------------------------------------------------------------ the map */
/* THE CLIP IS LIVE CODE, NOT TIDINESS.
   overflow:visible lets a star's halo and a line's end bleed a little past
   the plot's box, into the margin the chrome leaves -- which is wanted, and
   is why it is set. What it ALSO does is let every edge paint wherever it
   reaches, and an edge may now run 4200 units. Measured at 1700 x 950 on a
   2x display: the plot's compositor layer came out 8025 x 754, 23.1 MB and
   4.7 times wider than the window.
   A layer that size is tiled, and panning it exposes tiles faster than the
   rasteriser fills them -- which paints the unfilled ones blank. That is the
   flashing vertical bands that appear along the bottom AFTER a drag, and the
   drag's own stutter.
   clip-path bounds the layer without taking the bleed away: 140px of margin
   on every side, far more than any halo needs, and the layer drops to
   1980 x 754 and 5.7 MB. Measured against the unclipped render with motion
   frozen, 672 pixels of 6,120,000 differ by more than 8/255 and the largest
   difference is 19. Do not remove this to "let the map breathe" -- the
   breathing room is the -140px. */
.sky-map{
  position:absolute;inset:0;width:100%;height:100%;display:block;
  overflow:visible;
  clip-path:inset(-140px);
}
.sky-map:focus{outline:none}

/* ---- the lines ----------------------------------------------------------
   Faint at rest: at a glance the map should read as stars with a suggestion of
   structure, not as a wiring diagram. The structure arrives on hover. */
/* ---- the cluster silhouette --------------------------------------------
   Only when the chart is on AND one cluster is isolated. It says "this is one
   constellation" at a weight just above nothing — a hull, not a box, because
   a box reads as a UI element round some stars and a hull reads as the shape
   the stars themselves make. */
.cl-hull{
  fill:none;stroke:rgba(220,225,245,.06);stroke-width:.5;
  vector-effect:non-scaling-stroke;pointer-events:none;
  stroke-linejoin:round;
  opacity:0;transition:opacity 520ms var(--ease);
}
.sky-map.is-chart.is-solo .cl-hull.is-shown{opacity:1;transition:opacity 620ms var(--ease) 120ms}

/* ---- the chart layer ----------------------------------------------------
   HIDDEN AT REST. The first screen is a photograph of the sky, not a diagram
   of one, and every line here is drawn only when somebody asks for it — by
   turning the constellations on, or by hovering a single star.

   `pathLength="1"` normalises every edge to a unit length, which is what lets
   one dash rule ink in curves of wildly different sizes. The ink-in runs
   stroke-dashoffset, staggered per cluster by --ink-d, so the fifteen
   constellations draw themselves one after another instead of appearing. */
.ln{
  stroke:var(--star-line);stroke-width:.6;vector-effect:non-scaling-stroke;fill:none;
  stroke-linecap:round;
  stroke-dasharray:1 1;stroke-dashoffset:1;
  opacity:0;
  /* Fading OUT: 700ms, no stagger — a chart being put away should go as one
     thing. The IN timings live on .is-chart below. */
  transition:stroke var(--d-base) var(--ease), stroke-width var(--d-base) var(--ease),
             opacity 700ms var(--ease), stroke-dashoffset 700ms var(--ease);
}
.sky-map.is-chart .ln{
  opacity:1;stroke-dashoffset:0;
  transition:stroke var(--d-base) var(--ease), stroke-width var(--d-base) var(--ease),
             opacity 320ms var(--ease) var(--ink-d,0ms),
             stroke-dashoffset 500ms cubic-bezier(.16,1,.3,1) var(--ink-d,0ms);
}
/* A crossing arc keeps its dotted treatment, so it cannot ink in — dasharray
   is already spoken for. It fades instead, and waits until the constellations
   have finished drawing: a cross-reference between two figures that are not
   there yet is noise. */
.ln[data-cross]{stroke-dasharray:2.5 7;stroke-dashoffset:0}
.sky-map.is-chart .ln[data-cross]{transition:opacity 420ms var(--ease) 1200ms}

/* The tick marks — four paths for all 484 of them, batched by tier. They
   arrive once the ink has landed, the way a draughtsman marks the ends after
   ruling the line. */
.ln-tick{
  fill:none;vector-effect:non-scaling-stroke;stroke-linecap:round;
  stroke:var(--star-line);stroke-width:.6;
  opacity:0;transition:opacity 500ms var(--ease);
}
.ln-tick[data-lt="bg"]{stroke:var(--star-line-b);stroke-width:.8}
.ln-tick[data-lt="a"]{stroke:var(--star-line-a);stroke-width:1}
.ln-tick[data-lt="x"]{stroke:var(--star-line-x)}
.sky-map.is-chart .ln-tick{opacity:1;transition:opacity 500ms var(--ease) 1250ms}

/* Hovering a single star lights ITS edges even with the chart off. That is
   the whole interaction when the constellations are hidden: the sky stays a
   photograph and one relationship is drawn on it, briefly. */
.ln.is-lit{opacity:1;stroke-dashoffset:0;
  transition:opacity 220ms var(--ease), stroke-dashoffset 220ms var(--ease),
             stroke var(--d-base) var(--ease), stroke-width var(--d-base) var(--ease)}
/* Out slower than in: a reveal should arrive promptly and leave unhurriedly,
   or moving between two nearby stars reads as a flicker. */
.sky-map:not(.is-chart) .ln:not(.is-lit){
  opacity:0;transition:opacity 320ms var(--ease), stroke-dashoffset 320ms var(--ease);
}
/* A line between two clusters is a cross-reference, not part of a figure, and
   at the same weight as the figures it turns the whole map into a lattice —
   forty-eight arcs crossing fifteen constellations reads as neither. Drawn at
   a third of the weight at rest and lit with everything else on hover, so the
   information is still there and the shapes still read. */
.ln[data-cross]{stroke:var(--star-line-x);stroke-dasharray:2.5 7}

/* ---- magnitude, applied to the lines ------------------------------------
   An edge inherits the brighter of the two stars it joins, so a constellation's
   spine is drawn heavier than its outlying structure. Three weights is enough
   to read as hierarchy and few enough to stay quiet. dasharray is reset on the
   two lit tiers or a crossing spine would come up as dots.

   These sit AFTER the [data-cross] rule on purpose — same specificity (0,2,0),
   and source order is what decides which wins on a crossing spine. */
.ln[data-lt="bg"]{stroke:var(--star-line-b);stroke-width:.8}
.ln[data-lt="a"]{stroke:var(--star-line-a);stroke-width:1}
.ln[data-cross][data-lt="bg"],.ln[data-cross][data-lt="a"]{stroke:var(--star-line-x)}

/* ---- a topic star -------------------------------------------------------
   RESTING. Published stars are warm, larger and haloed; upcoming ones are
   cool, smaller and bare. That difference is the only thing that says which is
   which — there is no status text anywhere on the map. */
/* The map moves, so it must not read as a picture -- "not moving at ALL" was
   as much about there being no affordance as about there being no motion.
   Both classes are set by sky.js and only once it has actually wired the
   gesture up: a grab cursor over a map that cannot move is a worse lie than
   no cursor at all, and with JavaScript off or the pan range at zero this
   stays the default arrow.

   is-panning goes on the PLOT for the length of the gesture, so grabbing has
   to beat the star's own pointer -- otherwise dragging off a star shows a
   link cursor the whole way. */
.sky-plot.can-pan{cursor:grab}
.sky-plot.is-panning,.sky-plot.is-panning .nd{cursor:grabbing}
.nd{cursor:pointer}
.nd:focus{outline:none}
.nd-hit{fill:transparent;stroke:none}          /* direct hits only; see the note by its radius */
.nd-dot{
  fill:var(--star-cool);
  transition:fill var(--d-base) var(--ease), opacity var(--d-base) var(--ease);
}
.nd-halo{
  fill:url(#nd-glow);opacity:.5;
  transition:opacity var(--d-base) var(--ease);
}
.nd[data-status="published"] .nd-dot{fill:var(--star-warm)}
.nd[data-status="upcoming"] .nd-dot{fill:var(--star-dim);opacity:.72}

/* ---- magnitude ----------------------------------------------------------
   SIZE is set in the markup (TIER_R in build.py) and carries the connectivity
   tier. What is left here is the small amount of BRIGHTNESS the tier is
   allowed to borrow — and it is deliberately small, because brightness is
   spoken for: it says published or upcoming, and the plate's own caption
   promises that. So an alpha star that has not been investigated is the
   biggest star in its constellation and still cooler than any published one.
   A large faint star is an ordinary thing on a chart, and it is exactly what
   an important topic with no episode yet is. */
.nd[data-status="upcoming"] .nd-dot{opacity:.62}
.nd[data-status="upcoming"][data-tier="a"] .nd-dot{opacity:.95}
.nd[data-status="upcoming"][data-tier="b"] .nd-dot,
.nd[data-status="upcoming"][data-tier="g"] .nd-dot{opacity:.84}
/* The alpha of a published cluster is the one place amber appears on a star:
   the warmest point of the warmest tier, and there are only two of them. */
.nd[data-status="published"][data-tier="a"] .nd-dot{fill:var(--star-amber)}

/* ---- bloom and bleed ----------------------------------------------------
   Two things a point source does to a lens and to an eye, and neither of them
   is "be a disk".

   BLEED is the soft outer bloom every bright star gets: the star's own disk,
   held behind it at about a third opacity. There are 88 of them now — every
   cluster alpha plus the brightest 30% of the betas. An earlier note here
   said nineteen, which was true when the map held ninety stars; it holds 747.

   THIS USED TO BE A LIVE FILTER, AND THAT WAS THE BUG. It was
   filter:url(#nd-bleed), an feGaussianBlur, on all 88 — inside an SVG whose
   CSS rotate changes on EVERY animation frame (--sky-rot, see DRIFT_RATE in
   sky.js). A filtered element cannot be composited from a cached raster when
   its layer keeps re-orienting, so the browser re-rasterised 88 blur regions
   per frame, each with a 340% filter region. On a large high-DPI display that
   overruns the tile budget and the compositor shows the tiles it has not
   finished as blank — which reads as white flashes sweeping across the field.
   It scaled in with the map: at ninety stars there were nineteen filters and
   nobody saw it.

   The blur of a solid disc is a fixed radial falloff, so it does not need a
   filter at all. Three pre-computed radial gradients carry the three star
   colours (a gradient stop cannot read currentColor from the shape that
   references it, which is why there are three rather than one), the circle
   radius now covers what the filter region used to, and the per-frame
   rasterisation is gone. Nothing may put a live filter back on a node that
   sits inside the rotating plot.

   BLOOM is the real thing, and only the five brightest stars on the plate get
   it: three times the halo radius. It used to be mix-blend-mode:screen so it
   would ADD light. Against this navy the screened and the plainly composited
   result differ by under two parts in a thousand — measured, not assumed —
   and screen forces its own compositing context and is what turns a
   half-rasterised tile WHITE rather than merely blank. Not worth it. */
.nd-bleed{
  fill:url(#nd-bleed-warm);opacity:.35;pointer-events:none;
}
.nd[data-status="upcoming"] .nd-bleed{fill:url(#nd-bleed-dim);opacity:.26}
.nd[data-status="published"][data-tier="a"] .nd-bleed{fill:url(#nd-bleed-amber)}

.nd-bloom{
  fill:url(#nd-bloom);pointer-events:none;
}
/* An upcoming star blooms cooler and less. Brightness is still the channel
   that says investigated, and bloom is brightness. */
.nd[data-status="upcoming"] .nd-bloom{opacity:.62}

/* ---- the diffraction cross ----------------------------------------------
   One per cluster alpha — 68 of them now, not the fifteen an earlier note
   here claimed. A real telescope draws this with
   the vanes holding its secondary mirror, and it is the one detail that makes
   a field read as photographed rather than rendered. Stroked with a gradient
   in objectBoundingBox units, so one <defs> entry serves all fifteen and the
   falloff runs from the star out to nothing at each tip. */
.nd-spike{
  stroke:url(#nd-spike);stroke-width:.6;fill:none;
  vector-effect:non-scaling-stroke;pointer-events:none;
}
/* An upcoming star's spike is fainter, for the same reason its halo is: the
   brightness channel says investigated, and a spike is brightness. */
.nd[data-status="upcoming"] .nd-spike{opacity:.62}

/* The chromatic fringe rides on the alpha halos only — see the gradients in
   build.py. Beta and gamma halos are too small to carry a ring legibly. */
.nd[data-tier="a"] .nd-halo{fill:url(#nd-glow-a)}
.nd[data-status="upcoming"][data-tier="a"] .nd-halo{fill:url(#nd-glow-a-cool)}

/* Halo strength follows status, halo SIZE follows tier (set in the markup),
   and an upcoming star takes the COOL glow — the warm one is the published
   signal here and on every other page. */
.nd[data-status="upcoming"] .nd-halo{fill:url(#nd-glow-cool);opacity:.34}
.nd[data-status="upcoming"][data-tier="a"] .nd-halo{opacity:.66}

/* THE ALPHA PULSE IS GONE, AND THIS IS WHY.

   It was a six-second opacity breath on the alpha halos -- fifteen of them
   when it was written, one per constellation, so 68 once the map had 68
   constellations. The note beside it was careful about the right thing and
   missed the larger one: it kept the animation off the .nd group so the
   group's children would not re-rasterise, and then the animation promoted
   them anyway.

   Opacity is a compositable property, so each animated halo became its own
   compositor layer -- and Chromium's overlap testing then promoted every
   element painting ON TOP of one. Measured at the owner's window size
   (1970 x 1080, DPR 2): 198 composited layers, 329 MB of backing store, of
   which 68 nd-bleed circles at 43.1 MB, 12 nd-greek letters at 12.4 MB, 17
   nd-hit circles at 8.8 MB and 6 nd-dot circles at 5.5 MB were pure
   collateral -- roughly 70 MB and a hundred layers that exist because of one
   animation on their siblings.

   Add the field canvas (39.9 MB), the three offscreen layer bitmaps sky.js
   keeps at the same size (~120 MB) and the plot itself (31.7 MB) and the page
   is asking for well over 400 MB of raster. Past the tile budget the
   compositor evicts and re-rasterises on demand, and a tile it has not
   finished is painted blank -- which is the white flashing.

   Animating fill-opacity instead would dodge the promotion and is worse: a
   non-compositable property repaints on the main thread, which would
   re-raster the whole 9409 x 884 plot layer every frame instead.

   A breath between .85 and 1.0 opacity, on a halo, over six seconds, is not
   worth a hundred layers. If it ever comes back it belongs on the canvas
   field, which is already redrawn every frame and would carry it for free. */

/* ---- the Greek letter ---------------------------------------------------
   One per constellation, on its alpha star only. Two things at once: it is
   the idiom of a printed star chart, and it is the idiom of the biochemistry
   this map is about — alpha-tocopherol, beta-cell, gamma-linolenic. Sized in
   USER UNITS, not pixels, so it scales with the map the way the stars do;
   ~9px at a 1440px plot. */
.nd-greek{
  font-family:var(--display-grotesk);font-size:12px;font-weight:500;
  fill:rgba(240,235,220,.35);
  pointer-events:none;
  opacity:0;
  transition:fill var(--d-base) var(--ease), opacity 500ms var(--ease);
}
/* A letter lands after its own cluster's edges have finished drawing. */
.sky-map.is-chart .nd-greek{
  opacity:1;
  transition:fill var(--d-base) var(--ease),
             opacity 460ms var(--ease) calc(var(--ink-d,0ms) + 380ms);
}

/* ---- being touched ------------------------------------------------------
   Hover, keyboard focus and the touch equivalent are one state with three
   doors into it. The rest of the map drops to a quarter, so the relationship
   is the only thing left lit.

   THIS DIM WAS THE WHITE FLASHING, AND IT TOOK THREE WRONG GUESSES TO FIND.

   It used to read `.sky-map.is-dimmed .nd{opacity:.25}`. Each .nd is a GROUP
   -- an <a> holding a hit circle, a halo, a bleed, a dot, sometimes a spike
   and a letter -- and opacity on a group is not a paint parameter. It is a
   render surface: the group has to be drawn into its own buffer and then
   composited at that alpha, because the children must dim as one object
   rather than each showing through the others.

   One buffer is nothing. Seven hundred and forty-three of them, every time
   the pointer crosses a star, is not. Measured over CDP at 1970 x 1080 on a
   2x display:

       at rest          27 layers,   259 MB
       during a hover  375 layers,  1703 MB
       sweeping across 472 layers,  2013 MB

   Two gigabytes of backing store while the mouse moves. The GPU process
   cannot hold that, so it evicts tiles and re-rasterises them on demand, and
   a tile it has not finished is painted blank -- white flashes sweeping
   across the sky, exactly and only while hovering. It is invisible in
   headless Chromium, which rasterises in software and never tiles, which is
   why two earlier fixes went to the wrong places.

   The fix is to stop asking for the surface. fill-opacity and stroke-opacity
   are PAINT parameters: they multiply into the colour and never allocate
   anything. They also multiply with each element's own opacity, so every
   per-status variant above -- the cool halo at .34, the alpha at .66, the
   bleed at .26 -- keeps its relationship without being restated here.

   The one visible difference is that overlapping parts of the same star now
   dim independently rather than as one silhouette. At a quarter alpha on a
   navy ground that is not a difference anyone can see, and it is the whole
   cost of the page not flashing.

   NEVER put opacity on .nd, .nds or any group inside the plot. */
.sky-map.is-dimmed .nd:not(.is-focus):not(.is-near) .nd-dot,
.sky-map.is-dimmed .nd:not(.is-focus):not(.is-near) .nd-halo,
.sky-map.is-dimmed .nd:not(.is-focus):not(.is-near) .nd-bleed,
.sky-map.is-dimmed .nd:not(.is-focus):not(.is-near) .nd-greek{fill-opacity:.25}
.sky-map.is-dimmed .nd:not(.is-focus):not(.is-near) .nd-spike{stroke-opacity:.25}
/* A line is a single stroked path with no children, so it has nothing to
   composite as a unit -- but stroke-opacity is the same answer and costs
   nothing to be consistent about. */
.sky-map.is-dimmed .ln:not(.is-lit){stroke-opacity:.25}
.sky-map.is-dimmed .ln.is-lit{
  opacity:1;stroke:var(--star-line-lit);stroke-width:1;
  /* The glow is a second, wider stroke behind the first, not a filter: an SVG
     blur here would re-rasterise the whole group on every hover. */
  filter:drop-shadow(0 0 2.5px rgba(255,240,210,.45));
}

/* The letter comes up with its own cluster — peeked, soloed or hovered — and
   sits back down again. It is an index, not a label: it should be findable
   when you are looking for it and invisible when you are not. */
.sky-map.is-peek .nd.is-shown .nd-greek,
.sky-map.is-solo .nd.is-shown .nd-greek,
.nd.is-focus .nd-greek,
.nd.is-near .nd-greek{fill:rgba(240,235,220,.9)}

.nd.is-focus .nd-dot{fill:#FFFFFF}
.nd.is-focus .nd-halo{opacity:1}
.nd.is-near .nd-dot{fill:var(--star-pale)}
.nd.is-near .nd-halo{opacity:.85}
/* An upcoming star lights, but stays under a published one even when lit:
   the brightness difference is the only thing telling them apart, and
   collapsing it on hover would throw that away exactly when it is being read. */
.nd[data-status="upcoming"].is-focus .nd-dot{fill:var(--star-cool);opacity:1}
.nd[data-status="upcoming"].is-near .nd-dot{fill:var(--star-cool);opacity:.92}

/* The focus ring is a stroke on the dot itself rather than a second circle:
   one fewer element per star across ninety of them, and paint-order puts it
   behind the fill so a 4px dot does not become a 4px ring. */
.nd:focus-visible .nd-dot{stroke:var(--rust-ink);stroke-width:2;
  vector-effect:non-scaling-stroke;paint-order:stroke}


/* ------------------------------------------------------------------ the HUD
   Top right, inside the sky, below the nav. Everything editorial about the
   map lives here — the legend, the plate caption and the one note the visual
   grammar cannot make for itself — set as one block of marginalia rather than
   scattered around the composition. */
.sky-hud{
  position:absolute;z-index:3;
  top:calc(var(--sky-chrome-h) + .9rem);
  /* Aligned with the CONTENT COLUMN's right edge, not the viewport's. .wrap is
     max-width:var(--content) centred with a --gutter, so on a wide screen the
     header, the strip and every band stop well short of the window edge. This
     block used to sit clamp(1rem,4vw,2.5rem) from the viewport instead, which
     put it 365px outside the content column on a 1990px screen -- the caption
     hanging alone out over empty sky, which is exactly what it looked like.
     The max() keeps the original inset as a floor, so on narrow screens where
     the column already fills the window nothing moves. */
  right:max(clamp(1rem,4vw,2.5rem),
            calc((100vw - var(--content)) / 2 + var(--gutter)));
  /* Wider than the Archivo version was: a monospaced cap is about a tenth
     wider per character, and the plate caption has to stay on one line. */
  /* Wider than the button row, because only the BUTTONS take the pointer —
     the caption and note are pointer-events:none and can run past the
     keep-out corner without covering a star. This width is what keeps the
     two caption lines to two lines. */
  max-width:min(38rem, 42vw);
  display:flex;flex-direction:column;align-items:flex-end;gap:9px;
  pointer-events:none;text-align:right;
}
/* The legend is a DISCLOSURE at every width. Twenty-one group names plus the
   notes opened as a wall of type in the corner of the map; the plate caption
   and the drag hint stay outside the panel so the figure still names itself
   while the list is shut.
   The rule reaches the body with `~`, not `+`. It used to use `+` while
   .sky-turn sat between the button and the body, so it matched NOTHING: on a
   phone, tapping Filter set aria-expanded="true" over a panel that never
   opened, and the topic filters were unreachable. The markup now puts the body
   immediately after the button as well, so neither half can break the other. */
.sky-hud-toggle{
  display:inline-flex;align-items:baseline;gap:.5em;pointer-events:auto;
  appearance:none;cursor:pointer;
  background:rgba(11,20,35,.74);border:1px solid var(--line-ink);
  border-radius:var(--r-sm);padding:5px 10px;
  font-family:var(--display-grotesk);font-size:.625rem;font-weight:500;
  letter-spacing:.12em;text-transform:uppercase;color:var(--hud-ink);
  backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
}
.sky-hud-toggle:hover,.sky-hud-toggle:focus-visible{color:var(--on-ink);border-color:var(--line-ink-str)}
.sky-hud-toggle span{font-size:.75em;transition:transform var(--d-fast) ease}
.sky-hud-toggle[aria-expanded="true"] span{transform:rotate(180deg)}
.sky-hud-body{display:none}
.sky-hud-toggle[aria-expanded="true"] ~ .sky-hud-body{
  display:flex;flex-direction:column;align-items:flex-end;gap:8px;
  pointer-events:auto;
  background:rgba(11,20,35,.9);border:1px solid var(--line-ink);
  border-radius:var(--r-sm);padding:10px 12px 11px;
  backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
  min-height:0;overflow-y:auto;overscroll-behavior:contain;
}

/* ---- find a topic -------------------------------------------------------
   First in the panel, above the groups, because the groups NARROW and this
   FINDS -- and somebody who already knows the name should not have to work
   out which of twenty-one constellations it lives in.

   It takes the toggle's own treatment (translucent ink, hairline, 2px
   radius), so the panel reads as one instrument. The grotesque stays, the
   tracked caps do not: nobody types in capitals, and an input styled like a
   label is an input that looks disabled. */
.sky-search{
  position:relative;display:flex;align-items:center;gap:7px;
  /* flex:none, because .sky-hud-body is a column flex box that the phone
     gives a definite height (top AND bottom), so its children shrink by
     default. The results list was being crushed from 94px of real rows to
     6px -- present in the DOM, tappable by a test, invisible to a person. */
  flex:none;
  width:100%;max-width:min(28rem, calc(32vw - clamp(1rem,4vw,2.5rem)));
  margin-left:auto;pointer-events:auto;
  background:rgba(198,217,232,.06);
  border:1px solid var(--line-ink);border-radius:var(--r-sm);
  padding:0 7px 0 9px;
  transition:border-color var(--d-fast) var(--ease),
             background var(--d-fast) var(--ease);
}
.sky-search:focus-within{
  border-color:var(--line-ink-str);
  background:rgba(198,217,232,.11);
}
.sky-search-i{
  width:12px;height:12px;flex:none;color:var(--hud-ink);
  transition:color var(--d-fast) var(--ease);
}
.sky-search:focus-within .sky-search-i{color:var(--rust-ink)}
.sky-search-in{
  flex:1 1 auto;min-width:0;
  appearance:none;background:none;border:0;outline:0;
  padding:7px 0;
  font-family:var(--display-grotesk);font-size:.6875rem;font-weight:500;
  letter-spacing:.02em;color:var(--on-ink);
}
.sky-search-in::placeholder{color:var(--hud-ink);letter-spacing:.06em}
/* The clear button is the only tap target in the row, so it gets real size
   rather than the icon's 12px. */
.sky-search-x{
  appearance:none;background:none;border:0;cursor:pointer;flex:none;
  display:inline-flex;align-items:center;justify-content:center;
  width:24px;height:24px;margin-right:-4px;padding:0;color:var(--hud-ink);
  transition:color var(--d-fast) var(--ease);
}
.sky-search-x[hidden]{display:none}
.sky-search-x svg{width:11px;height:11px}
.sky-search-x:hover,.sky-search-x:focus-visible{color:var(--on-ink)}

.sky-results{
  list-style:none;margin:0;padding:0;flex:none;   /* see .sky-search */
  width:100%;max-width:min(28rem, calc(32vw - clamp(1rem,4vw,2.5rem)));
  margin-left:auto;pointer-events:auto;text-align:right;
  max-height:min(14rem,34vh);overflow-y:auto;overscroll-behavior:contain;
  /* A hairline under the answers, so finding and narrowing read as two zones
     rather than one long column. It only exists while there are results. */
  border-bottom:1px solid var(--line-ink);padding-bottom:5px;
}
.sky-results[hidden]{display:none}
.sky-results li{
  cursor:pointer;padding:5px 8px;border-radius:var(--r-sm);
  display:flex;flex-direction:column;gap:1px;
  transition:background var(--d-fast) var(--ease);
}
.sky-results b{
  font-family:var(--ui);font-size:.75rem;font-weight:500;
  color:var(--on-ink-body);letter-spacing:.005em;
}
.sky-results span{
  font-family:var(--display-grotesk);font-size:.5625rem;font-weight:500;
  letter-spacing:.11em;text-transform:uppercase;color:var(--hud-ink);
}
/* Selection is a ground, not the accent: rust already means "investigated"
   two lines below, and a second meaning for it here would be a lie. */
.sky-results li[aria-selected="true"]{background:rgba(198,217,232,.13)}
.sky-results li[aria-selected="true"] b{color:var(--on-ink)}
.sky-results-none{
  margin:0;flex:none;width:100%;
  max-width:min(28rem, calc(32vw - clamp(1rem,4vw,2.5rem)));
  margin-left:auto;pointer-events:auto;text-align:right;
  font-family:var(--display-grotesk);font-size:.5625rem;font-weight:500;
  letter-spacing:.11em;text-transform:uppercase;color:var(--hud-ink);
}
.sky-results-none[hidden]{display:none}
.sky-results-none a{color:var(--on-ink-body);border-bottom:1px solid var(--line-ink-str)}
.sky-results-none a:hover{color:var(--rust-ink);border-bottom-color:var(--rust-ink)}

/* Text, letter-spaced, separated by a middle dot. No boxes: a legend is
   marginalia, and fifteen bulleted pills is a wall of UI standing on top of
   the thing it is labelling. */
.sky-filters{
  pointer-events:none;flex:none;      /* same reason as .sky-search */
  display:flex;flex-wrap:wrap;justify-content:flex-end;
  column-gap:0;row-gap:2px;
  /* The BUTTONS are the only part of the HUD that takes the pointer, so they
     are the only part that has to stay inside the corner the layout keeps
     clear (KEEPOUT in build.py, the right 32%). The caption and the note are
     pointer-events:none and may run wider — which is what keeps the plate
     caption on one line. Widening the whole block instead put a button over
     glp-1 at 1280px; the width sweep in the working notes checks all fifteen
     widths for exactly this. */
  /* 32vw is KEEPOUT's own left edge (0.680), less the HUD's right gutter:
     the row is allowed exactly the corner the layout keeps clear, and not
     a pixel of the map. */
  max-width:min(28rem, calc(32vw - clamp(1rem,4vw,2.5rem)));
  /* The BOX hugs the right, not just its contents: .sky-hud-body is a plain
     block, so a narrowed row would otherwise sit at the left of it and put
     its buttons further into the map than the whole HUD reaches. */
  margin-left:auto;
}
.sky-filters button{
  appearance:none;background:none;border:0;cursor:pointer;pointer-events:auto;
  padding:2px 0;
  font-family:var(--display-grotesk);font-size:.625rem;font-weight:500;
  /* .10em and 42%: the legend is a thing you go looking for, not a thing that
     should catch your eye while you are reading the sky. It was competing. */
  letter-spacing:.10em;text-transform:uppercase;
  color:var(--hud-ink);
  transition:color var(--d-fast) ease;
}
/* Colour-led, not weight-led: the row is already at medium, so a pressed
   label going heavier would shift the whole row's rhythm as you click along
   it. The accent does the work. */
.sky-filters button[aria-pressed="true"]{color:var(--rust-ink)}
.sky-filters button + button::before{
  content:"·";margin:0 .5em;color:var(--hud-dot);
  /* On the separator, not between two elements, so a wrapped row never starts
     with an orphaned dot. */
}
.sky-filters button:hover,.sky-filters button:focus-visible{color:var(--on-ink)}

/* ---- the constellations toggle -----------------------------------------
   Leftmost, before ALL, because it governs the whole chart layer while the
   group labels only select within it. An 8px outlined square that fills when
   active: the smallest control that still says "this is a switch, and it is
   currently off" without a word of explanation. */
.sky-chart-toggle{display:inline-flex;align-items:center;gap:.62em}
.sky-chart-box{
  width:8px;height:8px;flex:0 0 8px;
  border:1px solid currentColor;border-radius:0;   /* a chart glyph, not a checkbox */
  background:transparent;
  transition:background var(--d-base) var(--ease);
}
.sky-chart-toggle[aria-pressed="true"]{color:var(--on-ink-body)}
.sky-chart-toggle[aria-pressed="true"] .sky-chart-box{background:currentColor}
/* The dot separator belongs between the GROUP labels; the toggle is not one
   of them, so it gets a rule of its own instead. */
.sky-filters .sky-chart-toggle + button::before{content:none}
.sky-chart-toggle{
  margin-right:.7em;padding-right:.7em;
  border-right:1px solid var(--hud-dot);
}

/* The alpha note describes something only on screen when the chart is. */
.sky-note-chart{opacity:0;transition:opacity 420ms var(--ease)}
.has-chart .sky-note-chart{opacity:1;transition:opacity 500ms var(--ease) 1250ms}

.sky-cap{
  font-family:var(--display-grotesk);font-size:.625rem;font-weight:500;
  letter-spacing:.14em;text-transform:uppercase;color:var(--hud-mute);
  line-height:1.8;
}
.sky-cap b{color:var(--on-ink-body);font-weight:700;white-space:nowrap}
/* The caption stacks under the Topics button rather than running out sideways.
   Constrained on .sky-cap and NOT on .sky-hud: the HUD's width is what the open
   filter panel uses, and narrowing that would fold twenty-one group names into
   a tall thin column. .sky-hud is align-items:flex-end, so a narrower caption
   simply right-aligns beneath the button. */
.sky-cap{max-width:14.5rem}
.sky-cap i{font-style:normal}
.sky-note{
  font-family:var(--display-grotesk);font-size:.625rem;font-weight:500;
  letter-spacing:.14em;text-transform:uppercase;color:var(--hud-mute);
}

/* ---- what the filter does to the map -----------------------------------
   Two strengths: peeking at a label dims the rest to a fifth, committing to
   one drops the rest almost out. JS marks the members rather than CSS matching
   two attributes against each other, which it cannot do. */
.sky-map.is-peek .nd,.sky-map.is-peek .ln{opacity:.2}
.sky-map.is-solo .nd,.sky-map.is-solo .ln{opacity:.08}
.sky-map.is-peek .nd.is-shown,.sky-map.is-solo .nd.is-shown{opacity:1}
.sky-map.is-peek .ln.is-shown,.sky-map.is-solo .ln.is-shown{opacity:1}
/* Hovering INSIDE an isolated group still has to dim that group's other
   stars, or the hover says nothing once a filter is on. These come last on
   purpose: they tie with the .is-shown rules above on specificity, and a tie
   is resolved by source order. */
/* With the chart off, a line that is not lit is not there AT ALL — not at a
   quarter, not at a fifth. The point of hovering on a clean sky is that one
   relationship is drawn on it and nothing else is. The rule that does this
   lives with the hover timings below, after the filter rules it ties with on
   specificity — source order settles it. */

.sky-map.is-dimmed .nd.is-shown,.sky-map.is-dimmed .ln.is-shown{opacity:.25}
.sky-map.is-dimmed .nd.is-focus,.sky-map.is-dimmed .nd.is-near{opacity:1}
.sky-map.is-dimmed .ln.is-lit{opacity:1}
/* A star you have tabbed to is never faint, whatever the filter is doing —
   a focus ring at 8% opacity is not a focus ring. */
.sky-map .nd:focus-visible{opacity:1}

/* ----------------------------------------------------------- the tooltip
   Name, status, one line. It appears under a cursor that is already moving,
   so anything longer is not read. */
.sky-tip{
  position:absolute;top:0;left:0;z-index:6;
  width:max-content;max-width:min(260px, 70vw);
  pointer-events:none;
  background:rgba(11,20,35,.92);
  border:1px solid rgba(240,235,220,.18);   /* a hairline, not a frame */
  border-radius:var(--r-sm);                /* 2px: a card, not a bubble */
  padding:10px 14px;
  box-shadow:0 10px 30px rgba(0,0,0,.40);
  backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
  /* Opacity only, and only on the way in. Animating the box would resize it
     under a cursor that is already moving, which is how a tooltip turns into
     something you chase. */
  opacity:0;transition:opacity 120ms var(--ease);
  will-change:transform;
}
.sky-tip.is-on{opacity:1}
.sky-tip-name{
  font-family:var(--disp);font-size:15px;line-height:1.3;color:var(--on-ink);
}
/* The grotesque, like the rest of the map's metadata — this line is a plate
   reading, not a sentence. See the note on --display-grotesk in the tokens
   for why the map is the one place on the site with a third register. */
.sky-tip-status{
  margin-top:4px;
  font-family:var(--display-grotesk);font-size:10px;font-weight:500;
  letter-spacing:.14em;text-transform:uppercase;color:var(--on-ink-faint);
}
.sky-tip-status.is-live{color:var(--rust-ink)}
.sky-tip-hint{
  margin-top:7px;font-size:12.5px;line-height:1.45;color:rgba(230,225,210,.75);
}

/* ------------------------------------------------- the card for an upcoming
   topic. An upcoming star has no page — a page whose entire content is a
   promise is worse than the promise — so the promise is made here instead,
   next to the star, with the one thing a visitor can actually do about it. */
/* The card used to be a paper-white slab carrying a newsletter button, and it
   was wrong twice. It was the only light object in a night sky, so it read as
   a browser dialog rather than as part of the map; and its one action sent a
   visitor who had asked about a NAMED TOPIC to a generic mailing-list page.

   It is now the tooltip's own material at card size -- same ground, same
   hairline, same blur -- and the whole box is one link to that topic's page.
   Every star has a page now (see build_sky_pages), so there is somewhere
   honest to send them. */
.sky-card{
  position:absolute;top:0;left:0;z-index:7;
  width:min(23rem, calc(100vw - 2rem));
  background:rgba(11,20,35,.94);
  color:var(--on-ink-body);
  border:1px solid rgba(240,235,220,.18);
  border-radius:var(--r-sm);
  padding:18px 20px 20px;
  box-shadow:0 18px 50px rgba(0,0,0,.5);
  backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
  will-change:transform;
  transition:border-color var(--d-fast) var(--ease);
}
.sky-card .lab{color:var(--on-ink-mute)}
.sky-card .lab::before{background:var(--rust-ink)}
.sky-card h2{font-size:var(--t-h3);margin-top:10px;color:var(--on-ink)}
.sky-card-hint{margin-top:9px;color:var(--on-ink-body);font-size:.9375rem;line-height:1.55}

/* The stretched link. The anchor sits on the title so its accessible name is
   the topic; the pseudo-element is what makes the rest of the box clickable.
   Everything else in the card is inert, so nothing competes with it. */
.sky-card-link{color:inherit;text-decoration:none;border:0}
.sky-card-link::after{content:"";position:absolute;inset:0;z-index:1;border-radius:inherit}
.sky-card:hover{border-color:rgba(240,235,220,.34)}
.sky-card:hover h2,
.sky-card:focus-within h2{color:var(--star-amber)}

/* The focus ring belongs on the CARD, because the card is what is clickable --
   a ring drawn tight around the title would point at a quarter of the target.
   It is drawn on the stretched pseudo-element, which already covers the box,
   so no :has() is needed and the ring lands exactly on the hit area.

   :focus-visible, NOT :focus-within. showCard() moves focus to this link every
   time the card opens, so that Enter opens the topic for a keyboard visitor --
   which means a plain focus rule would paint a two-pixel ring around the card
   on every mouse click as well. :focus-visible is the distinction: the browser
   withholds it when the focus followed a pointer. */
.sky-card-link:focus-visible{outline:none}
.sky-card-link:focus-visible::after{outline:2px solid var(--star-amber);outline-offset:3px}

.sky-card-go{
  display:inline-flex;align-items:center;gap:8px;margin-top:16px;
  font-family:var(--disp);font-size:.8125rem;letter-spacing:.06em;
  text-transform:uppercase;color:var(--on-ink-mute);
  transition:color var(--d-fast) var(--ease);
}
.sky-card-go svg{width:14px;height:14px;transition:transform var(--d-fast) var(--ease)}
.sky-card:hover .sky-card-go,
.sky-card:focus-within .sky-card-go{color:var(--star-amber)}
.sky-card:hover .sky-card-go svg{transform:translateX(3px)}
.sky-card-x{
  position:absolute;top:10px;right:10px;z-index:2;
  appearance:none;background:none;border:0;cursor:pointer;
  width:30px;height:30px;border-radius:var(--r-sm);color:var(--steel);
  display:flex;align-items:center;justify-content:center;
  transition:color var(--d-fast) ease, background var(--d-fast) ease;
}
.sky-card-x svg{width:14px;height:14px}
.sky-card-x{color:var(--on-ink-mute)}
.sky-card-x:hover,.sky-card-x:focus-visible{color:var(--on-ink);background:rgba(240,235,220,.10)}

/* ---- the horizon --------------------------------------------------------
   An abstract dark ridge across the bottom of the frame, at z-index 1 so it
   sits over the star field and under the seam gradient.

   Its fill runs from 60% at the jagged edge to ZERO at the bottom, which does
   two things at once: it reads as a distant ridge dissolving into haze, which
   is what aerial perspective actually looks like, and it means the shape has
   no weight at all where the sky meets the hero. A solid silhouette there
   would be fifteen levels darker than --ink and would undo the seam. */
/* ================================================ the ambient sky (v4.5)
   Six hundred decorative stars live on the CANVAS, not here -- see LAYERS
   in sky.js. They were SVG circles first; 600 of them under a transform
   that changes every frame took the plate from 20fps to 12.5fps, and
   neither will-change nor contain recovered it. Nothing that carries no
   data needs to be in the DOM. */

/* Reserved capacity: computed, shipped, and shown to nobody. `hidden` on the
   group keeps all two hundred out of layout, out of paint and out of the
   accessibility tree at once. See MASTER.md 5c for how one gets promoted. */
.sky-slots{display:none}

/* ------------------------------------------------------------------ the moon
   A baked raster, not an SVG construction and not a WebGL sphere.

   The SVG version it replaces was a cream circle, a clipped shadow circle,
   three blurred ellipses and two gradient halos. It was geometrically honest
   -- the terminator was solved from the phase equation -- and it still read
   as a drawn graphic, because what makes a moon look like a moon is a
   million craters, and you cannot draw a million craters with nine elements.

   So the surface comes from real lunar maps instead. tools/render-moon.py
   projects an albedo map and an elevation map onto a sphere, lights it with
   the lunar-Lambert photometric function that planetary imaging uses, casts
   real shadows near the terminator, and bakes the result. See MASTER.md 8d.

   Why not three.js: ~150KB gzipped, four times this site's entire JS budget,
   plus a second GPU surface competing with the canvas star field -- to draw
   one object that never changes. Baking supersamples 3x offline and ships 2x
   for retina, which is more fidelity per byte than a realtime sphere at this
   size can reach. The only thing it has to do at runtime is scale on hover.

   POSITION. Hard left, vertically a little below centre, far from the HUD:
   the legend and caption live in the top right, so the left half of the
   plate was the one large empty region on the page. It is an anchor now, not
   an accent.

   Z-ORDER, and the one real tension in this change. A body in front of the
   stars occludes them -- that is what makes it read as a body rather than as
   a decal. But the 111 topic stars are the site's content index, and a 380px
   disc sitting on top of them hides about fifteen.

   Both, then, on the switch this page already has:

     photographic mode (the default)  moon ABOVE the plot. It occludes stars
                                      and chart lines the way the real one
                                      does. This is the view that has to look
                                      like astronomy.
     chart mode (constellations on)   moon drops BEHIND the plot and dims, so
                                      every topic and every line is legible.
                                      This is the view that has to work.

   pointer-events stays none in both, so the moon never takes an event from a
   star underneath it. Every one of the 111 stays hoverable, keyboard
   reachable and crawlable whichever mode is on -- only the painting changes.
   The hover scale is driven from sky.js, so the moon does not need events of
   its own. See MASTER.md 8d. */
.sky-moon{
  position:absolute;z-index:1;pointer-events:none;
  /* Vertically placed inside the VISIBLE sky, not the section. .sky runs up
     behind the masthead by --sky-chrome-h, so a plain percentage put the moon
     under the strip on a phone -- where the chrome is 188px of a 544px
     section -- and "AUDIO & VIDEO" was printed across the terminator. This
     form is correct at any chrome height without a second breakpoint. */
  left:var(--moon-x);
  top:calc(var(--sky-chrome-h) + (100% - var(--sky-chrome-h)) * var(--moon-y));
  /* Sized as a FRACTION OF THE VISIBLE SKY, not as a length.
     
     A fixed px size is the wrong unit here and it hid a real bug. The phone's
     sky is 544px tall carrying 188px of chrome, so only 356px of it is
     visible; the desktop's is 900 carrying 148, so 752 is. The same "small"
     110px was 31% of the visible sky on a phone against 20% on desktop --
     half again as large where there is least room for it. Percentage height
     resolves against .sky, so this is self-correcting on any device and
     cannot drift again.

     height only, with aspect-ratio for the width: a percentage WIDTH would
     resolve against the container's width and the moon would go oval. */
  height:min(calc((100% - var(--sky-chrome-h)) * var(--moon-k)), var(--moon-max));
  width:auto;aspect-ratio:1;
  /* Parallax only. The moon does NOT turn with the sky, and that is both a
     composition decision and the physically honest one: the Moon is the one
     object up there that does not share the stars' motion -- it runs about
     thirteen degrees a day eastward against them. An anchor that walks three
     hundred pixels off its mark in an hour is not an anchor. */
  transform:translate3d(calc(var(--sky-px,0px) * .34), calc(var(--sky-py,0px) * .34), 0)
            translate(-50%, -50%);
}
.sky-moon-img{
  display:block;width:100%;height:100%;
  /* Transform only, so nothing here can move anything else on the page. */
  transform:scale(1);
  will-change:transform;
}
/* The scale is driven from sky.js rather than :hover, because the topic stars
   sit in the same stack and a pointer crossing one would break a CSS hover
   and snap the moon back mid-transition. sky.js already tracks the pointer
   for parallax, so this costs one distance test per frame and no layout.

   --moon-hover is read by sky.js too, so the hit test knows how big the disc
   becomes. Keeping the number in ONE place is what stops the two drifting:
   a hit radius that did not grow with the disc would drop the hover the
   moment the pointer moved into the area the growth had just covered. */
.sky.is-moon-near .sky-moon-img{transform:scale(var(--moon-hover))}

/* Chart mode: behind the plot, and down a little so 242 hairlines and fifteen
   Greek letters stay readable where they cross it. */
.has-chart .sky-moon{z-index:0}
.has-chart .sky-moon-img{opacity:.74}
.sky-moon-img{transition:transform 900ms cubic-bezier(.22,.61,.36,1),
                         opacity 420ms cubic-bezier(.22,.61,.36,1)}

/* --moon-y is a FRACTION of the visible sky, not a length.

   --moon-k sets the RESTING size as a share of the visible sky and
   --moon-hover is what it reaches under the pointer. The two are set so that rest x hover lands exactly on the
   geometry v4.6 shipped and verified -- 380px at 1440, 270 at 1024, 203 at
   768 -- which means the clearance measured at twelve breakpoints still
   holds for the largest the moon ever gets.

   At rest it is an ACCENT, not the subject. 152px against a 900px sky is
   about a sixth of its height, which is roughly what a real moon subtends
   against a sky you are actually looking at -- and it leaves the field of
   stars as the thing the eye lands on, which is what this plate is for. It
   reaches full size only when you go to it.

   The diameter scales with the viewport rather than stepping at breakpoints.
   Stepping put a 320px moon on a 768px-wide tablet, where the desktop HUD is
   still in force but has run out of room to the right: measured 9px between
   the disc and the filter row. A continuous ramp never produces that case. */
/* --moon-k is the share of the visible sky the moon takes; --moon-max caps
   it on a tall narrow window, where a fifth of the sky would still be wide
   enough to reach the HUD. */
.has-sky{--moon-k:.20;--moon-max:clamp(80px, 10.56vw, 152px);--moon-hover:2.5;
         --moon-x:20%;--moon-y:.47}
@media (max-width:767.98px){
  /* On a phone the plate is 390 wide. At 210px the disc ran off the left edge
     and took half the map with it; 190 at 30% keeps the whole sphere on
     screen with the constellations still readable beside it. */
  /* No hover on a touch screen, so what the phone rests at is all it ever
     is. It needs no size of its own now -- --moon-k gives it the same share
     of sky the desktop gets, which is the whole point of expressing it that
     way. */
  .has-sky{--moon-hover:1;--moon-x:30%;--moon-y:.42}
}
/* A short viewport has almost no sky below the chrome, so the anchor gives
   way rather than crowding the map. */
@media (min-width:768px) and (max-height:700px){
  .has-sky{--moon-max:clamp(72px, 12vh, 120px);--moon-y:.46}
}
@media (prefers-reduced-motion:reduce){
  .sky-moon-img{transition:none}
  .sky.is-moon-near .sky-moon-img{transform:scale(1)}
}

/* ----------------------------------------------------- the finishing pass
   A vignette and a grain, in that order, over the sky and nothing else. Both
   do the same job — stopping the frame reading as a flat digital rectangle —
   and neither should be visible as itself. They sit above the map and below
   the HUD (z-index 2 against 3), so the legend and the tooltip stay clean.

   Two elements rather than one with a ::after, because an element carrying
   mix-blend-mode blends with its BACKDROP, and a z-indexed parent would have
   trapped the grain inside its own stacking context and blended it with the
   vignette instead of with the sky. */
/* THE THREE OVERLAYS ARE PROMOTED ON PURPOSE, AND THIS IS THE FIFTH THING
   THAT CAUSED THE FLASHING AT THE BOTTOM EDGE.

   .sky-field is a canvas that animates, so the compositor gives it a layer.
   Everything painting ABOVE a composited layer has to go somewhere, and
   Chromium's answer is a SQUASHING layer: one surface holding every
   non-composited thing that overlaps it. The horizon ridge that used to sit
   here, .sky-finish and .sky-grain were all z-index 1-2 over that canvas, so
   all three were squashed together -- and measured over CDP at 1700 x 950 on
   a 2x display, that surface came out 1700 x 1660, straddling the bottom seam
   and fully repainted on every frame of a drag.

   Giving each of them its own layer costs small static surfaces that never
   repaint, and takes the bottom edge out of the per-frame paint path. (The
   ridge has since gone altogether: see the note where it was, in
   block_sky().)

   This is a stopgap. The canvas underneath still re-rasterises 8.2 megapixels
   per frame, which is what the WebGL renderer exists to end. */
.sky-finish{
  position:absolute;inset:0;z-index:2;pointer-events:none;
  will-change:transform;transform:translateZ(0);
  /* NOT AT THE BOTTOM. A vignette closes a frame, and the bottom of this one
     is not the end of the picture -- the scene carries on into the section
     below, which has no vignette. Left on, it took the bottom corners about
     seven levels below the ink they meet, so the seam was a darker curve at
     both ends. Faded out over the last 190px on a smooth curve, it frames the
     top and the sides as before and leaves the ground open. (The lens glow
     in ::after has its own, shorter mask for the same reason; the two
     compose.) */
  -webkit-mask-image:linear-gradient(to top,
    transparent 0, rgba(0,0,0,.10) 38px, rgba(0,0,0,.34) 80px,
    rgba(0,0,0,.66) 124px, rgba(0,0,0,.90) 164px, #000 190px);
  mask-image:linear-gradient(to top,
    transparent 0, rgba(0,0,0,.10) 38px, rgba(0,0,0,.34) 80px,
    rgba(0,0,0,.66) 124px, rgba(0,0,0,.90) 164px, #000 190px);
  /* Corners 40% deeper than v4.3 (.18 -> .25), centre untouched. Cinematic
     framing works by being invisible where you are looking and present where
     you are not. */
  background:radial-gradient(122% 92% at 50% 46%,
    rgba(0,0,0,0) 44%, rgba(0,0,0,.085) 72%, rgba(0,0,0,.25) 100%);
}
/* The lens: a cool fringe around the inside of the frame, what a real piece of
   glass does at the edge of its circle.

   The soft 40px glow only — NOT the 0.5px hairline the brief also asks for.
   A crisp ring on the right edge exists above the seam and not below it, so it
   put a 5.8-level step at x=1439 where everything else was under two. Measured
   both ways: with the hairline 5.79, without it 2.15. The glow is the part
   that reads as glass; the hairline was costing the one guarantee four passes
   have protected to deliver something invisible at 8% alpha. */
.sky-finish::after{
  content:"";position:absolute;inset:0;
  box-shadow:inset 0 0 40px rgba(140,155,200,.055);
  /* Masked out of the bottom 130px. ANY edge treatment that stops at the
     sky's lower boundary puts a step there, because it exists above the seam
     and not below it — the hairline did it at 5.8 levels and the soft glow
     still did it at 5.2. Fading the whole lens effect to nothing before it
     reaches the seam is the only version that costs the seam nothing. */
  -webkit-mask-image:linear-gradient(to top, transparent 0, #000 130px);
  mask-image:linear-gradient(to top, transparent 0, #000 130px);
}
/* fractalNoise rather than turbulence: turbulence clumps into a visible weave
   at this scale. Not background-attachment:fixed — repainting a filtered SVG
   against the viewport on every scroll frame costs more than the effect. */
.sky-grain{
  position:absolute;inset:0;z-index:2;pointer-events:none;
  will-change:transform;transform:translateZ(0);
  /* The grain sits above the fade, so it was the one texture still at full
     strength on the last row of the sky -- and the section below has none.
     On ground that flat, texture that stops along a straight line reads as
     the line. It thins out over the last 160px instead, so both sides of the
     boundary are the same clean ink. A mask on a static, promoted overlay is
     applied by the compositor and never re-rasterised; nothing here reads
     the moving canvas underneath. */
  -webkit-mask-image:linear-gradient(to top,
    transparent 0, rgba(0,0,0,.14) 34px, rgba(0,0,0,.45) 76px,
    rgba(0,0,0,.80) 122px, #000 160px);
  mask-image:linear-gradient(to top,
    transparent 0, rgba(0,0,0,.14) 34px, rgba(0,0,0,.45) 76px,
    rgba(0,0,0,.80) 122px, #000 160px);
  /* baseFrequency 0.67 puts one grain particle at about 1.5px, which is film
     grain; the 0.82 this was drawn at put it at 1.2px, which is closer to
     sensor noise. The tile is rendered smaller on a phone (170 x 1/1.5 = 113)
     so the particle lands at ~1px there — scaling the tile scales the grain,
     which is cheaper than shipping a second noise image. */
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.67' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:170px 170px;
  opacity:.04;
  /* NO BLEND MODE, AND THIS IS THE FOURTH THING THAT CAUSED THE FLASHING.
     This element is z-index 2 and covers the whole section, so it sat over
     the map. mix-blend-mode makes a compositor READ its backdrop, and the
     backdrop here is the one thing on the page that changes constantly: the
     plot's viewBox moves on every frame of a drag, so the grain had to
     re-blend against a surface that was re-rasterising underneath it. Where
     a backdrop tile was not filled yet, `overlay` against it resolved
     WHITE -- and most violently along the bottom, where .sky::after and
     .sky-finish have already taken the real backdrop nearly to black, so an
     unfilled tile is as far from the truth as it can get. Vertical bands,
     along the bottom, only while dragging.
     The blend was also worth nothing. Measured at 1700 x 950 on a 2x
     display with motion frozen, overlay against normal differs by a mean of
     2.0 of 255 with a maximum of 7, and NOT ONE pixel of 6,120,000 differs
     by more than 8 -- which is what four percent opacity buys you. The grain
     stays; the backdrop read goes. */
}

/* --------------------------------------------------------------- the veil
   The hero below carries a handful of the same stars at very low opacity, so
   the night does not stop at a section boundary. Painted by sky.js into a
   canvas that is decoration and nothing else. */
.sky-veil{
  position:absolute;inset:0;width:100%;height:100%;
  display:block;pointer-events:none;z-index:1;
  /* No opacity here: the canvas paints at the exact alpha it means to, so
     the one number that matters is in one place (see veil() in sky.js). */
}

/* -------------------------------------------------------------- the phone
   Ninety stars on a 390px screen is soup, and a 90-target hit grid on a
   thumb is a lottery. Below 768px only the `key` nodes are drawn — about
   thirty-five, chosen as every published topic plus the anchor of each group —
   their targets grow to a thumb, and parallax is off entirely (there is no
   cursor to parallax against, and the drift only costs battery). */
/* Deliberately small, and the same at every width: this is the DIRECT-hit
   target only, and it must never overlap its neighbour (the closest two stars
   the layout produces are 54 units apart, so 10 is safe with room). Everything
   wider than a direct hit is resolved by nearest-star testing in sky.js, which
   is what actually makes the map usable with a thumb. */
.nd-hit{r:10}
/* The HUD keeps its corner at every width — the layout holds that corner
   clear (see KEEPOUT in build.py) so no star is ever drawn beneath it. What
   changes below 1280px is only how much of the sky it is allowed to take:
   a legend wider than a third of the frame stops being marginalia and starts
   being the composition. */
@media (max-width:1279.98px){
  .sky-hud{max-width:min(27rem,52vw);gap:8px}
}

@media (max-width:767.98px), (max-height:560px){
  /* These two rules used to read
       .nd:not([data-key]){display:none}
       .ln:not([data-key]){display:none}
     and they were the whole reason a phone only ever showed 35 topics. The
     phone field could not hold more, so 96 of them were hidden outright —
     which meant every node added after that shipped invisible on a phone.
     The celestial sphere is what replaced the constraint: all 131 are placed,
     and the horizon decides what is up rather than an attribute deciding what
     exists. Nothing is hidden by status any more. */
  /* Crossing arcs stay off by default on a phone, though not for the reason
     they used to. It was that the far figure was not drawn at all; now it is
     drawn but may be BELOW THE HORIZON, so a third of these arcs still run to
     a star that is not on screen. A dozen of them at a 2.5/7 dash read as
     dirt on the lens rather than as cross-references. Held at zero until a
     star is tapped, which is when the relationship is actually being asked
     about — and tapping is also what brings the arc's far end to mind. */
  /* The 284 arcs and 15 hulls are invisible in the default state, but an
     invisible element still gets re-rastered when the sphere turns under it.
     Measured across a drag: p50 33.3ms with them, 16.7ms without — exactly
     the difference between 30fps and 60.

     visibility:hidden and not display:none, because visibility leaves them in
     layout and the opacity transitions that ink them in still run; display
     would kill those. content-visibility:hidden measured no better than
     shipping them (p95 50ms), so it is not the answer here either.

     A hull is only ever drawn in chart mode anyway. A line can be lit by a
     tap outside it, so that one case opts back in. */
  .sky-map:not(.is-chart) .lns,
  .sky-map:not(.is-chart) .hulls{visibility:hidden}
  .sky-map:not(.is-chart) .ln.is-lit{visibility:visible}
  .ln[data-cross]{opacity:0}
  /* Fifteen letter-spaced words wrap into five rows on a 390px screen, and
     five rows of buttons across the top of the map take the pointer from
     every star beneath them — measured at 390px, where eight of the top rows
     returned a <button> from elementFromPoint instead of a star. So the whole
     legend collapses to one word. Closed, it is a small chip in the corner
     the layout already keeps clear; open, it is a panel the next tap dismisses. */
  .sky-hud{
    top:calc(var(--sky-chrome-h) + .5rem);
    /* Bottom as well as top, so the open panel has a DEFINITE height to be
       bounded by. Without it a monospaced list of fifteen groups ran past the
       end of the sky, which is overflow:hidden, and the last four were clipped
       and untappable. */
    bottom:.75rem;
    right:clamp(.75rem,4vw,1.5rem);
    max-width:min(19rem, calc(100vw - 1.5rem));gap:7px;
  }
  /* The toggle and the panel are in the base rules now. */
  /* Two columns, no separators, one group per cell. Fifteen monospaced labels
     in an inline row wrap to one-per-line on a 390px screen, which is a
     fifteen-row list — taller than the sky. The middle dot is a separator for
     a ROW; in a grid it is just noise. */
  .sky-hud-toggle[aria-expanded="true"] ~ .sky-hud-body .sky-filters{
    display:grid;grid-template-columns:1fr 1fr;
    column-gap:10px;row-gap:0;width:100%;max-width:none;
  }
  .sky-hud-body .sky-filters button{
    /* 34px of row: past WCAG 2.5.8's 24px on its own, and close to the 44px
       a thumb wants. The panel scrolls internally, so it can afford them. */
    padding:10px 0;text-align:right;letter-spacing:.1em;
  }
  .sky-hud-body .sky-filters button + button::before{content:none}
  /* The desktop cap exists to keep the buttons out of the map's keep-out
     corner. The phone panel already has its own width, so the search and its
     results simply fill it. */
  .sky-search,.sky-results,.sky-results-none{max-width:none}
  /* 16px, not 12: iOS Safari zooms the whole page into any input set below
     16px the moment it takes focus, and does not zoom back out. */
  .sky-search-in{font-size:16px;padding:9px 0}
  .sky-results{max-height:min(11rem,26vh)}
  /* Nothing hovers on a phone. The plate still names itself; the affordance
     clause is the part that is not true here, so it is the part that goes. */
  .sky-cap i{display:none}
  .sky-tip{max-width:min(17rem,78vw)}
  /* ~1px grain on a phone; see the note by the tile. */
  .sky-grain{background-size:113px 113px}
}
@media (max-width:520px){
  .sky-note{font-size:.5625rem;letter-spacing:.11em;padding:5px 11px}
}

/* --------------------------------------------------------- reduced motion
   The twinkle, the drift, the scroll zoom and the shooting stars all stop.
   The sky does not empty: sky.js still paints the field once, statically,
   because removing nine hundred stars from the people who asked not to be
   moved is not the accommodation they asked for. */
@media (prefers-reduced-motion:reduce){
  .sky-stage,.sky-plot{transform:none}
  .ln,.nd-dot,.nd-halo,.nd-greek,.sky-filters button,.sky-skip,.sky-tip{transition:none}
  .nd[data-tier="a"] .nd-halo{animation:none}
  /* The chart cuts in and out rather than inking. Someone who asked not to be
     moved did not ask for a twelve-hundred-millisecond drawing animation. */
  .ln,.ln-tick,.cl-hull,.sky-note-chart,
  .sky-map.is-chart .ln,.sky-map.is-chart .ln-tick,
  .sky-map.is-chart .nd-greek,.has-chart .sky-note-chart{transition:none}
  .ln{stroke-dasharray:none}
  .sky-map.is-chart .ln{stroke-dashoffset:0}
}
/* ------------------------------------------------- chrome sitting on the sky
   Scoped to html.has-sky so no other page is touched, and lifted the moment
   the sky is behind you. The header keeps its sticky behaviour throughout —
   only its paint changes. */
.has-sky .notice{background:transparent;border-bottom-color:rgba(198,223,242,.14);
  color:var(--on-ink-mute)}
.has-sky .notice b{color:var(--on-ink-body)}
.has-sky .hdr{background:transparent;border-bottom-color:transparent;
  backdrop-filter:none;-webkit-backdrop-filter:none;
  transition:background var(--d-base) var(--ease), border-color var(--d-base) var(--ease)}
/* The Noir lockup (site.css, tools/lockup.py) on the sky: the dark art, whose
   strand is ivory where the paper art's is ink, and the board's dark-ground
   type -- the name in --on-ink, the tagline in #9FB3CA, between --on-ink-body
   and --on-ink-mute (--steel is a light-ground colour and lands near 2:1 on
   the sky). The dark art is lazy, so this is the only page that fetches it. */
.has-sky .hdr .brand .lk-art--paper{display:none}
.has-sky .hdr .brand .lk-art--dark{display:block}
.has-sky .hdr .brand .lockup{--lk-name:var(--on-ink);--lk-tag:#9FB3CA}
.has-sky .hdr .brand:hover .lockup{--lk-name:var(--paper-alt)}
.has-sky .hdr .nav a:not(.btn){color:var(--on-ink-body)}
.has-sky .hdr .nav a:not(.btn):hover{color:var(--paper-alt)}
.has-sky .hdr .nav a:not(.btn)[aria-current="page"]{color:var(--paper)}
.has-sky .hdr .btn--solid{background:var(--paper);color:var(--ink)}
.has-sky .hdr .btn--solid:hover{background:var(--paper-alt);color:var(--ink)}
.has-sky .hdr .navtoggle{color:var(--paper);border-color:rgba(198,223,242,.32)}
.has-sky .strip{background:transparent;border-bottom-color:rgba(198,223,242,.12)}
.has-sky .strip .wrap{color:var(--on-ink-mute)}
.has-sky .strip em{color:var(--rust-ink)}

/* Past the sky, everything returns to the paper treatment it has everywhere
   else. These mirror the base rules rather than unsetting them, because
   `unset` on a custom-property-backed value resolves to the wrong thing. */
.has-sky.sky-passed .notice{background:var(--sand);border-bottom-color:var(--sand-line);
  color:var(--sand-text)}
.has-sky.sky-passed .notice b{color:var(--sand-label)}
.has-sky.sky-passed .hdr{background:color-mix(in srgb, var(--paper) 88%, transparent);
  border-bottom-color:var(--line);
  backdrop-filter:blur(12px) saturate(150%);-webkit-backdrop-filter:blur(12px) saturate(150%)}
.has-sky.sky-passed .hdr .brand .lk-art--paper{display:block}
.has-sky.sky-passed .hdr .brand .lk-art--dark{display:none}
.has-sky.sky-passed .hdr .brand .lockup{--lk-name:var(--ink);--lk-tag:var(--steel)}
.has-sky.sky-passed .hdr .brand:hover .lockup{--lk-name:var(--navy)}
.has-sky.sky-passed .hdr .nav a:not(.btn){color:var(--steel-deep)}
.has-sky.sky-passed .hdr .nav a:not(.btn):hover{color:var(--rust)}
.has-sky.sky-passed .hdr .nav a:not(.btn)[aria-current="page"]{color:var(--ink)}
.has-sky.sky-passed .hdr .btn--solid{background:var(--ink);color:var(--paper)}
.has-sky.sky-passed .hdr .btn--solid:hover{background:var(--navy);color:var(--paper)}
.has-sky.sky-passed .hdr .navtoggle{color:var(--ink);border-color:var(--line-strong)}
.has-sky.sky-passed .strip{background:var(--card);border-bottom-color:var(--line)}
.has-sky.sky-passed .strip .wrap{color:var(--steel)}
.has-sky.sky-passed .strip em{color:var(--rust)}

/* ------------------------------------------------------------- a topic page
   Same case-file language as an episode: a plate, a verdict, sources. */
.sky-head{background:var(--ink);color:var(--on-ink-body);padding-block:clamp(2.5rem,6vw,4.25rem);
  border-bottom:1px solid var(--line-ink-str)}
.sky-head h1{color:var(--on-ink);font-size:var(--t-h2);margin-top:14px;max-width:18ch}
.sky-head .lab{color:var(--on-ink-mute)}
.sky-head .lab::before{background:var(--rust-ink)}
.sky-gloss{font-family:var(--disp);font-size:var(--t-lead);line-height:1.62;
  color:var(--on-ink-body);max-width:44ch;margin-top:20px}
.sky-meta{display:flex;flex-wrap:wrap;gap:8px 28px;margin-top:26px;
  font-family:var(--label);font-weight:460;font-size:var(--t-label);letter-spacing:.15em;
  text-transform:uppercase;color:var(--on-ink-faint)}
.sky-meta b{color:var(--on-ink-body);font-weight:500}
.sky-body{padding-block:clamp(2.25rem,5vw,3.5rem)}
.sky-soon{border:1px solid var(--line-strong);background:var(--card);
  padding:clamp(1.5rem,4vw,2.5rem);max-width:var(--measure)}
.sky-soon h2{font-size:var(--t-h3)}
.sky-soon p{margin-top:12px;color:var(--steel-deep)}
/* ---- what a topic page carries before it has an investigation ----------
   Every node has a page now, and most of them have no episode behind them
   yet. These three blocks are what stops such a page reading as an empty
   promise: the tail of the coming-soon note says plainly that nothing here
   is a conclusion, the references show the reading already done, and the
   connections are the same edges the map draws lines along. */
.sky-soon-tail{margin-top:14px;font-size:.9375rem;color:var(--steel)}

.sky-refs{margin-top:clamp(2rem,4vw,2.75rem)}
.sky-refs-note{margin:.35rem 0 1.1rem;max-width:62ch;color:var(--steel);font-size:.9375rem}
.sky-refs .src-list{margin-bottom:0}

.sky-rel{margin-top:clamp(2rem,4vw,2.75rem)}
.sky-rel ul{display:flex;flex-wrap:wrap;gap:8px;margin-top:.9rem;padding:0;list-style:none}
.sky-rel a{
  display:inline-block;padding:7px 13px;
  border:1px solid var(--line-strong);border-radius:var(--r-sm);
  font-size:.9375rem;color:var(--steel-deep);text-decoration:none;
  transition:border-color var(--d-fast) ease, color var(--d-fast) ease;
}
.sky-rel a:hover,.sky-rel a:focus-visible{border-color:var(--navy);color:var(--navy)}

.sky-neighbours{margin-top:clamp(2rem,4vw,3rem)}
.sky-neighbours ul{display:flex;flex-wrap:wrap;gap:8px}
.sky-neighbours a{
  font-family:var(--label);font-size:.65rem;font-weight:500;letter-spacing:.15em;
  text-transform:uppercase;color:var(--steel-deep);
  border:1px solid var(--line-strong);border-radius:var(--r-sm);padding:6px 11px;
  transition:border-color var(--d-fast) ease,color var(--d-fast) ease;
}
.sky-neighbours a:hover{border-color:var(--navy);color:var(--navy)}

/* ---------------------------------------------------- the template page only
   /sky/_template.html documents the data shape in place. Nothing else on the
   site uses these, which is why they sit at the end rather than in the
   component sheet. */
.sky-doc{max-width:var(--measure);margin-top:clamp(2rem,4vw,3rem)}
.sky-doc h2{font-size:var(--t-h3);margin-top:clamp(1.75rem,3.5vw,2.5rem)}
.sky-doc h2:first-child{margin-top:0}
.sky-doc p{margin-top:12px;color:var(--steel-deep)}
.sky-doc ul{margin-top:12px;padding-left:1.1em;list-style:disc;color:var(--steel-deep)}
.sky-doc li{margin-top:6px}
.sky-doc code{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:.84em;background:var(--sand);
  border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--navy)}
.sky-fields{width:100%;margin-top:18px;border-collapse:collapse;
  font-size:.9rem;color:var(--steel-deep)}
.sky-fields th{text-align:left;font-family:var(--label);font-weight:590;font-size:var(--t-label);
  letter-spacing:.15em;text-transform:uppercase;color:var(--steel);
  border-bottom:1px solid var(--line-strong);padding:0 14px 8px 0}
.sky-fields td{border-bottom:1px solid var(--line);padding:10px 14px 10px 0;
  vertical-align:top}
.sky-fields tr:last-child td{border-bottom:0}
.sky-fields td:first-child,.sky-fields td:nth-child(2){white-space:nowrap}
@media (max-width:640px){
  .sky-fields,.sky-fields tbody,.sky-fields tr,.sky-fields td{display:block;width:100%}
  .sky-fields thead{display:none}
  .sky-fields tr{border-bottom:1px solid var(--line);padding-block:12px}
  .sky-fields tr:last-child{border-bottom:0}
  .sky-fields td{border-bottom:0;padding:2px 0;white-space:normal}
  .sky-fields td:first-child,.sky-fields td:nth-child(2){white-space:normal}
  .sky-fields td:nth-child(2)::before{content:"e.g. ";color:var(--steel)}
}

/* ---------------------------------------------------- the phone, as a panorama
   Under GL a phone shows the same panorama as the desktop, through a window
   that keeps the world's full height (fitWindow in sky.js). These are the
   few things that follow from that, keyed on .is-gl-intent, which sky.js sets
   before the first build when GL is going to draw -- so the moon is placed
   for the right composition from the first frame. The SVG fallback's phone
   keeps the sphere and everything above that was written for it. */
@media (max-width:767.98px), (max-height:560px){
  /* The desktop moon's own world point, (460, 546), in the gap between two
     constellations. The phone window is height-fitted, so one world unit is
     plot-height / 1090 px, and the plot is min(140vw, 36rem) tall: 460 x that
     / 1090 = .422 of it. --moon-y is already a fraction of the visible sky,
     and .47 is where 546 of 1090 lands. */
  .sky.is-gl-intent{--moon-x:calc(min(140vw, 36rem) * .422);--moon-y:.47}
  /* The chip stays the size it is drawn; its target does not. An invisible
     extension takes it to 44px tall, and the card's close to 44px square. */
  .sky-hud-toggle{position:relative}
  .sky-hud-toggle::after{content:"";position:absolute;inset:-10px -8px}
  .sky-card-x::after{content:"";position:absolute;inset:-7px}
  /* Daylight contrast. The hint and the chip were 2.6-3.1:1 on this ground;
     the desktop, read at a desk, keeps its quieter values. */
  .sky-turn{color:rgba(228,236,246,.64)}
  .sky-hud-toggle{color:rgba(230,225,210,.68)}
  .sky-hud,.sky-card{-webkit-tap-highlight-color:transparent}
  /* The panorama runs stars and constellation lines right up to the top of
     the plot, under the hint and the caption; the desktop's top-right corner
     happens to be a gap between constellations, a 390px window's is not. A
     halo of the sky's own ink behind the letters stops a lit line striking
     through the words, and on bare sky it is invisible. */
  .sky.is-gl-intent .sky-turn,.sky.is-gl-intent .sky-cap{
    text-shadow:0 0 2px rgba(10,15,28,.95),0 0 6px rgba(10,15,28,.85),0 0 12px rgba(10,15,28,.6)}
}
/* A phone on its side. The sphere wanted a 576px plot; a panorama needs only
   enough height to keep neighbouring stars 24px apart (61.9 world units at
   the closest), which is 432px -- so 144px less to scroll past before the
   page starts. The moon follows the plot height. */
@media (max-height:560px) and (orientation:landscape){
  .sky.is-gl-intent{min-height:calc(var(--sky-chrome-h) + var(--sky-plot-b) + 27rem);
                    --moon-x:calc(27rem * .422)}
}
/* A tablet held upright. Its window is fitted too, at the desktop's own
   scale (Z_CAP, .63), so the moon's world x of 460 is 290px from the plot's
   left edge -- not the 20% of the width that places it on the desktop, which
   on a 768 put the disc over two stars. */
@media (min-width:768px) and (orientation:portrait){
  .sky.is-gl-intent{--moon-x:290px}
}
@media (prefers-contrast:more){
  .sky-turn,.sky-cap,.sky-hud-toggle{color:rgba(230,225,210,.88)}
}
/* The homepage's canvas colour, for the overscroll above the sky and below
   the footer: both dark, where the page's cream flashed above the masthead. */
html.has-sky{background:var(--ink)}

/* The homepage's light-on-dark nav colours were written for the inline
   desktop nav. At 900px and below the same links sit in the drawer, on the
   drawer's white card, where they measured 1.48:1. There, the drawer keeps
   the site's own colours. */
@media (max-width:900px){
  .has-sky .hdr .nav a:not(.btn){color:var(--steel-deep)}
  .has-sky .hdr .nav a:not(.btn):hover{color:var(--rust)}
  .has-sky .hdr .nav a:not(.btn)[aria-current="page"]{color:var(--ink)}
  .has-sky .hdr .nav .btn--solid{background:var(--ink);color:var(--paper)}
  .has-sky .hdr .nav .btn--solid:hover{background:var(--navy);color:var(--paper)}
}
