/* ---------- scrollbars ----------
   Every scrolling surface in the game is a dark panel, and an unstyled bar is
   a bright grey slab down the edge of it — the one piece of chrome that never
   got dressed. Styled once, globally, so a new panel inherits it instead of
   remembering to.

   Both syntaxes are here on purpose: `scrollbar-*` is the standard and is what
   Firefox honours, `::-webkit-scrollbar` is what Chrome, Edge, Safari and the
   Android WebView still need. They do not conflict — a browser ignores the one
   it does not implement. */
*{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--accent) 42%, var(--line)) transparent}

::-webkit-scrollbar{width:12px;height:12px}
::-webkit-scrollbar-track{background:transparent}

/* The thumb carries the epoch accent rather than a neutral grey, so the one
   piece of chrome the game never dressed now shifts colour as you advance
   with everything else. --line alone was almost the panel colour and read as
   absent; this is meant to be visible. */
::-webkit-scrollbar-thumb{
  background:color-mix(in srgb, var(--accent) 38%, var(--line));
  border-radius:100px;
  /* Transparent border with background-clip is the only way to inset a webkit
     thumb; padding and margin do nothing to it. */
  border:3px solid transparent;
  background-clip:padding-box;
}
::-webkit-scrollbar-thumb:hover{
  background:color-mix(in srgb, var(--accent) 78%, var(--line));
  background-clip:padding-box;
}
::-webkit-scrollbar-thumb:active{background:var(--accent);background-clip:padding-box}
::-webkit-scrollbar-corner{background:transparent}

/* ---------- fonts ----------
   Bundled, not fetched. See fonts/README.md: a CDN webfont vanishes offline
   and on file://, which are the two cases this game is built to survive, so
   the typography would have been the first thing to go exactly when everything
   else still worked.
   
   Space Grotesk is a VARIABLE font — one file covers 300 to 700, so asking for
   another weight costs nothing and adds no request. */
@font-face{
  font-family:"Source Serif 4";
  src:url("../fonts/source-serif-4-latin.woff2") format("woff2");
  font-weight:300 700;
  font-display:swap;
}
@font-face{
  font-family:"Space Grotesk";
  src:url("../fonts/space-grotesk-latin.woff2") format("woff2");
  font-weight:300 700;
  font-display:swap;
}

:root{
  --void:#17111f;
  --void-2:#211830;
  --line:#3a2d4d;
  --dust:#f6e3c0;
  --muted:#9384a8;
  --accent:#f0a35e;          /* shifts as you advance epochs */
  --accent-soft:#f0a35e33;

  /* one color per epoch, warm to cool — used for connectors and the sheet */
  --epoch-1:#f0a35e;
  --epoch-2:#ff8f6b;
  --epoch-3:#ff6f91;
  --epoch-4:#e8567a;
  --epoch-5:#c06fd0;
  --epoch-6:#a97bf0;
  --epoch-7:#6fd3d1;
  /* 8 is Divergence, the only epoch that hasn't happened yet — so it sits past
     the end of the warm-to-cool run rather than continuing it. */
  --epoch-8:#a9b8ff;

  /* Epoch 9, The Long Dark. Dimmer than every epoch before it and the only
     one that steps DOWN in brightness — the era it names has no new stars in
     it, and the ladder should say so before the flavour text does. */
  --epoch-9:#8892b8;

  /* Epoch 10, Evaporation. Dimmer again, and the last one. */
  --epoch-10:#5f6785;

  /* Colour does two jobs on a node, split by which PART of it you're looking at:
       inside  (glyph + fill) = the EPOCH  — identity, variety, 7 colours
       outside (ring + glow)  = the STATE  — owned / affordable / available
     That keeps "what can I afford" a one-glance question while letting the
     tree stop being one flat colour. Locked nodes reveal neither — an
     unknown shouldn't leak which era it belongs to.

     Ordered by how loudly each state should call for attention:
     affordable (act now) > available (soon) > owned (done) > locked. That's
     why owned is the quiet one — it's finished, it shouldn't compete. It's
     also deliberately NOT --epoch-1's hex any more; those two used to be the
     same colour, which collapsed the whole inside/outside distinction for
     the entire first epoch. */
  --state-owned:#6b5f80;
  --state-affordable:#6fd88f;
  --state-available:#6fa8f0;
  --state-available-soft:#6fa8f059;

  /* atmosphere + Firmament palette — kept here rather than inline in rules,
     same as every other colour in the project */
  --fog-warm:#6b4a7a55;
  --fog-cool:#7a5a6b4d;
  --fog-deep:#4a3a5e66;
  --sky-glow:#2a1d3f;        /* the light at the top of the Firmament view */
  --light-bright:#bff3f2;    /* a charted keystone, brighter than --epoch-7 */

  /* ---- the Celestial Canvas ----
     Struck from the EPOCH variables, not from new hex. The first version used
     a literal neon triad (#06b6d4 / #4f46e5 / #9333ea) and it read as a
     different application bolted on: those are cold blues, and this game is
     warm dusk from the Big Bang to Ascension. Three consequences of doing it
     this way, all of them the reason the rest of the stylesheet works like
     this already:
       - it harmonises by construction, because these are the colours the tree
         and the sheet are already using;
       - the Firmament keeps its established identity — --epoch-7 is the teal
         the star map, its portal and its sheet have always been;
       - a palette cosmetic recolours the whole Canvas for free, which a
         hardcoded hex could never do.
     Only the ground is its own value, and it stays in --void's hue family. */
  --canvas-void:#140f1e;     /* the Canvas ground — a shade under --void, same warm hue */
  --canvas-key:var(--epoch-7);    /* the Firmament's own colour: actions, lit state, readouts */
  --canvas-deep:var(--epoch-6);   /* the recessive half of the gradients */
  --canvas-glow:var(--epoch-5);   /* unallocated Light, and the role line */

  /* ---- real stellar colour, by spectral class ----
     O and B stars are genuinely blue-white and M stars genuinely red; these
     are approximate blackbody colours for each class. They live here as data
     rather than in the constellation markup so a star's colour is a property
     of its physics, and both themes get to re-strike them. */
  --spec-o:#9bb0ff;
  --spec-b:#aabfff;
  --spec-a:#cad8ff;
  --spec-f:#f6f4ff;
  --spec-g:#fff4e8;
  --spec-k:#ffd2a1;
  --spec-m:#ff9d6b;
  --scrim:#0a0710;           /* modal/backdrop wash, alpha applied per use */
  /* Note: the starfield dots stay as literal #ffffffNN inside their gradient
     lists. They're decorative alpha-white at nine different opacities, the
     file already used that convention before this, and color-mix() per dot
     would make the gradient unreadable for no gain. */

  /* ---- control sizing ----
     Every interactive thing in the game is at least --tap tall. 44px is the
     floor Apple's HIG and WCAG 2.5.5 both land on, and an audit found almost
     nothing here met it: the account toggle was 24px, the hint's close 29px,
     the quantity buttons 32px, the panel closes 34px, even the HUD buttons
     42px. Small targets aren't merely awkward on a phone, they're the reason
     a tap "does nothing" and gets repeated.

     These are geometry, not colour, so they are deliberately NOT re-struck in
     the light theme — the theme block is for palette only. */
  --tap: 46px;              /* minimum height of anything you can press */
  --hud-btn: clamp(46px, 5vw, 56px);   /* the round rail buttons, bigger on a desktop screen */
  --hud-icon: clamp(20px, 2.2vw, 24px);
}

/* A finger is not a mouse pointer. Where the primary input is coarse, the
   floor goes up rather than down — and the HUD buttons stop scaling with the
   viewport, because a tablet is a big screen you still touch. */
@media (pointer: coarse){
  :root{
    --tap: 48px;
    --hud-btn: 52px;
    --hud-icon: 23px;
  }
}

*{box-sizing:border-box;margin:0;padding:0}

html{
  -webkit-text-size-adjust:100%;   /* stop iOS auto-zooming the page's text scale */
  /* The root's own background, not just the body's.
     
     Only <body> was painted, and the browser paints the OVERSCROLL area from
     the root — so every rubber-band at the top or bottom of a scroll flashed
     the default white through, on a screen that is otherwise entirely dark.
     
     overscroll-behavior stops the chain as well as the flash: when an inner
     panel (the sheet, the guide, the shop) is scrolled to its end, the gesture
     used to pass up to the page and bounce it. Nothing here should ever bounce
     — the tree owns panning, and the page itself never scrolls. */
  background:var(--void);
  overscroll-behavior:none;
}
body{overscroll-behavior:none}

html, body{height:100%;overflow:hidden}   /* the tree owns scrolling now; the page itself never scrolls */
*{-webkit-tap-highlight-color:transparent}

/* Dragging the tree used to smear a text selection across every node label it
   passed, so the pan read as a highlight instead of a drag. Selection is off
   everywhere by default and switched back on only where text is genuinely
   meant to be read closely, copied, or typed. */
body{-webkit-user-select:none;user-select:none}
input, textarea,
.guide-panel, .codex-flavour, .sheet-flavour, .save-hint,
/* The Canvas is prose a player reads closely — what a constellation does, and
   the real astronomy behind it — sitting on a screen with no map to drag. */
.socket-effect, .socket-detail,
/* A friend code exists to be handed to somebody. The blanket rule above made
   your own code the one string in the game you couldn't copy. */
.friend-code{
  -webkit-user-select:text;user-select:text;
}
/* And on touch, a long press on a node used to raise the OS copy/share callout
   mid-drag. Same reasoning as touch-action:none on these two. */
.tree-viewport, .sky-viewport{-webkit-touch-callout:none}

body{
  /* Only the epoch glow lives here now — the stars moved to their own layer
     so they can fade in at First Light instead of always existing. */
  background:
    radial-gradient(900px 600px at 50% -10%, var(--accent-soft), transparent 70%),
    var(--void);
  color:var(--dust);
  /* Space Grotesk INHERITS to everything, and Source Serif 4 is applied
     deliberately where it earns its place.

     It was the other way round and the result read as dated: a serif at 13px
     across five paragraphs of Help is a journal, not an interface. The serif
     is a display face here — panel titles, node and star names, band names,
     card titles, the big numbers — where it has size and presence to carry.
     Running prose, buttons, inputs and every small functional label are the
     sans.

     Still one family for display words and one for everything else, so this
     does not walk back into the three-font arrangement that IBM Plex Sans and
     Space Grotesk and Share Tech Mono used to be. Space Grotesk carrying both
     body prose and numbers is one family doing two jobs, not two families
     fighting over one. */
  font-family:"Space Grotesk",sans-serif;
  transition:background-color 1.2s ease;
  position:relative;

  /* per-epoch atmosphere knobs, overridden by the body[data-epoch] rules */
  --mote-size:3px;
  --mote-opacity:.5;
}

/* ---------- atmosphere: the sky changes as the universe does ----------
   Driven by body[data-epoch], set in applyEpochColor(). Every value here is
   a slow transition, so advancing an epoch reads as the world changing
   rather than a repaint. All of it inherits the global
   prefers-reduced-motion rule at the bottom of this file. */
.starfield{
  position:fixed;inset:0;z-index:0;pointer-events:none;
  opacity:0;transition:opacity 3s ease;
  background:
    radial-gradient(1px 1px at 12% 22%, #ffffff77, transparent),
    radial-gradient(1px 1px at 78% 8%, #ffffff66, transparent),
    radial-gradient(1.5px 1.5px at 34% 64%, #ffffff5a, transparent),
    radial-gradient(1px 1px at 91% 48%, #ffffff55, transparent),
    radial-gradient(1px 1px at 60% 88%, #ffffff4a, transparent),
    radial-gradient(1.5px 1.5px at 8% 78%, #ffffff66, transparent),
    radial-gradient(1px 1px at 46% 14%, #ffffff50, transparent),
    radial-gradient(1px 1px at 24% 44%, #ffffff45, transparent),
    radial-gradient(1.5px 1.5px at 68% 72%, #ffffff5a, transparent);
}

/* The opaque early universe. Free electrons scattered every photon for
   380,000 years — so for two epochs the screen is genuinely fogged, and
   buying Recombination clears it. That clearing is the payoff. */
.epoch-fog{
  position:fixed;inset:0;z-index:1;pointer-events:none;
  opacity:0;transition:opacity 3.5s ease;
  background:
    radial-gradient(60% 50% at 30% 30%, var(--fog-warm), transparent 70%),
    radial-gradient(70% 55% at 72% 66%, var(--fog-cool), transparent 72%),
    radial-gradient(90% 70% at 50% 50%, var(--fog-deep), transparent 80%);
}

/* 1 Quantum Foam — no stars, thick seething fog, small fast motes */
body[data-epoch="1"] .epoch-fog{opacity:.5}
body[data-epoch="1"]{--mote-size:2px;--mote-opacity:.65}

/* 2 Recombination — the fog is at its heaviest right before it lifts */
body[data-epoch="2"] .epoch-fog{opacity:.68}
body[data-epoch="2"]{--mote-size:2.5px;--mote-opacity:.6}

/* 3 First Light — the fog is gone and the stars exist for the first time */
body[data-epoch="3"] .starfield{opacity:.7}
body[data-epoch="3"]{--mote-size:3px;--mote-opacity:.5}

/* 4 Heavy Elements — a denser, brighter sky */
body[data-epoch="4"] .starfield{opacity:.85}
body[data-epoch="4"]{--mote-size:3.5px;--mote-opacity:.5}

/* 5 Worlds — fewer, larger, slower grains: dust becoming rock */
body[data-epoch="5"] .starfield{opacity:.9}
body[data-epoch="5"]{--mote-size:4.5px;--mote-opacity:.45}

/* 6 Emergence — soft and organic */
body[data-epoch="6"] .starfield{opacity:.95}
body[data-epoch="6"]{--mote-size:4px;--mote-opacity:.55}

/* 7 Ascension and beyond — the clearest the sky ever gets */
body[data-epoch="7"] .starfield,
body[data-epoch="8"] .starfield{opacity:1}
body[data-epoch="7"],
body[data-epoch="8"]{--mote-size:3.5px;--mote-opacity:.6}

/* ---------- HUD: floats over the full-screen tree ---------- */
.hud-top{
  position:fixed;top:0;left:0;right:0;z-index:5;
  display:flex;flex-direction:column;align-items:center;gap:16px;
  padding:clamp(16px,4vw,28px) clamp(12px,4vw,18px) 30px;
  background:linear-gradient(var(--void) 0%, var(--void) 40%, transparent 100%);
  pointer-events:none;   /* lets drags pass through to the tree beneath except on real controls */
}

/* ---------- epoch banner ---------- */
.epoch-banner{text-align:center}
.epoch-label{
  font-size:11px;letter-spacing:.22em;text-transform:uppercase;
  color:var(--muted);font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
}
.epoch-name{
  font-family:"Source Serif 4",serif;font-weight:600;
  font-size:clamp(24px,6vw,34px);line-height:1.15;margin-top:7px;
  color:var(--accent);transition:color 1.2s ease;
}
.epoch-next{font-size:12.5px;color:var(--muted);margin-top:9px}

/* ---------- counter ---------- */
.counter{text-align:center}
.count{
  font-family:"Space Grotesk",sans-serif;
  font-size:clamp(26px,8vw,42px);
  /* tabular-nums is doing REAL WORK again, and this is the note that says so.

     The numeric face used to be Share Tech Mono, which is monospaced — every
     digit the same width by construction, so this property was belt-and-braces
     over a guarantee the font already gave. Space Grotesk is a proportional
     sans: its 1 is narrower than its 8. Without tnum a live counter reflows on
     every tick and shoves whatever sits beside it.

     So: never remove this from a rule that shows a number that changes. It is
     no longer a formality. */
  font-variant-numeric:tabular-nums;
  letter-spacing:-.02em;
}
.rate{font-size:13px;color:var(--muted);margin-top:5px;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums}

/* the cosmic clock — the age of the universe you're building. Deliberately
   quiet: it's atmosphere and context, not a stat you act on. */
.cosmic-clock{
  margin-top:9px;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--muted);
}
.cosmic-clock::before{content:"T + ";opacity:.55}
.cosmic-clock.at-present::before{content:""}

/* Time until the cheapest reachable node is affordable. Hidden the instant
   anything is buyable, so it informs during a wait and never nags. */
/* Reads as the prestige currency, not as another dust figure — it is the one
   line in the HUD denominated in something a collapse does not take away. */
.collapse-worth{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:12px;letter-spacing:.04em;margin-top:6px;
  color:var(--state-affordable);
  text-shadow:0 0 10px color-mix(in srgb, var(--state-affordable) 40%, transparent);
}
.collapse-worth[hidden]{display:none}

.next-unlock{
  display:none;
  margin-top:7px;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  color:var(--state-available);opacity:.9;
}
.next-unlock:not([hidden]){display:block}
/* The line says one of two things now: what you are saving toward when you can
   afford nothing, and what is worth buying when you can afford plenty. "next:"
   only fits the first, so the prefix belongs to that state rather than to the
   element. See updateNextUnlock(). */
.next-unlock::before{content:"next: ";opacity:.6}
.next-unlock.is-advice::before{content:"buy: "}

/* weekly + dated calendar cards */
.week-card{
  border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:12px;
}
.week-card:last-child{margin-bottom:0}
.week-card-name{
  font-family:"Source Serif 4",serif;font-weight:600;font-size:14px;color:var(--accent);
  margin-bottom:5px;
}
.week-card-desc{font-size:12.5px;line-height:1.6;color:var(--muted)}
.week-today{display:none;border-color:var(--accent)}
.week-today:not([hidden]){display:block}
.week-card-when{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.16em;
  text-transform:uppercase;color:var(--muted);margin-bottom:6px;
}

/* Time of day: an extremely light wash over everything, atmosphere only —
   never mechanical. Night is the baseline the game was designed in, so it
   adds nothing at all. */
.tod-wash{
  position:fixed;inset:0;z-index:1;pointer-events:none;
  opacity:0;transition:opacity 4s ease, background 4s ease;
}
body[data-tod="dawn"] .tod-wash{opacity:.16;background:linear-gradient(180deg, var(--epoch-2), transparent 60%)}
body[data-tod="day"]  .tod-wash{opacity:.10;background:linear-gradient(180deg, var(--epoch-7), transparent 55%)}
body[data-tod="dusk"] .tod-wash{opacity:.16;background:linear-gradient(180deg, var(--epoch-5), transparent 60%)}

/* records grid in the drawer */
.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px 16px}
.stat-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
.stat-label{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.14em;
  line-height:1.5;text-transform:uppercase;color:var(--muted);
}
.stat-value{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:15px;color:var(--dust);
  overflow-wrap:anywhere;
}

.event-bonus-badge{
  margin-top:6px;display:none;padding:3px 10px;border-radius:20px;
  background:var(--state-affordable);color:var(--void);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  pointer-events:none;
}
.event-bonus-badge:not([hidden]){display:inline-block}

/* The deep path's payout landing. Same hidden/display discipline as every
   other JS-toggled element here: display:none in the base rule, and the real
   value only once JS removes the attribute. */
.burst-flash{
  margin-top:6px;display:none;padding:3px 12px;border-radius:20px;
  background:color-mix(in srgb, var(--epoch-8) 22%, transparent);
  border:1px solid var(--epoch-8);color:var(--epoch-8);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  pointer-events:none;
}
.burst-flash:not([hidden]){display:inline-block;animation:burst-land .9s ease-out}
@keyframes burst-land{
  0%{transform:scale(.82);opacity:0}
  22%{transform:scale(1.06);opacity:1}
  100%{transform:scale(1);opacity:1}
}

.event-prompt{
  /* z-index 31 puts the Supernova window above every panel — the sheet (21),
     the drawer (25), the guide and codex (28). It used to sit at 7, behind
     all of them, so a window that opened while you were shopping was visible
     through the scrim, unclickable, and always missed. It's a 12-second
     window: it has to outrank whatever you happen to have open. */
  position:fixed;top:38%;left:50%;transform:translate(-50%,-50%) scale(.9);z-index:31;
  display:none;flex-direction:column;align-items:center;gap:8px;
  padding:20px 30px;border-radius:16px;cursor:pointer;
  background:var(--void-2);border:2px solid var(--state-affordable);
  box-shadow:0 0 30px -6px var(--state-affordable);
  opacity:0;pointer-events:none;transition:opacity .25s ease, transform .25s ease;
  animation:event-pulse 1.4s ease-in-out infinite;
}
.event-prompt:not([hidden]){display:flex}
.event-prompt.show{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
@keyframes event-pulse{50%{box-shadow:0 0 46px 4px var(--state-affordable)}}
.event-prompt-title{
  font-family:"Source Serif 4",serif;font-weight:600;font-size:20px;line-height:1.2;
  color:var(--state-affordable);
}
.event-prompt-sub{font-size:13px;line-height:1.5;color:var(--dust);font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums}

/* ---------- floating HUD buttons ----------
   Two rails rather than one stack of five. Which side a button is on carries
   meaning: LEFT changes the view and can never cost you anything, RIGHT opens
   something of yours. A player shouldn't have to remember which icon is which
   — the side already tells them what kind of thing it is. */
.hud-rail{
  position:fixed;z-index:6;top:clamp(16px,4vw,28px);
  display:flex;flex-direction:column;align-items:center;gap:10px;
}
.hud-rail-left{left:clamp(12px,4vw,18px)}
.hud-rail-right{right:clamp(12px,4vw,18px)}
.hud-rail-label{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:8px;letter-spacing:.2em;
  text-transform:uppercase;color:var(--muted);opacity:.55;margin-bottom:-2px;
}

.hud-btn{
  position:relative;
  width:var(--hud-btn);height:var(--hud-btn);border-radius:28%;
  background:var(--void-2);border:1px solid var(--line);
  display:grid;place-content:center;cursor:pointer;
  transition:border-color .18s ease, color .18s ease, transform .18s ease, box-shadow .18s ease;
  /* colour comes from the data-hue block below */
}
/* This class carries a bare `display`, so any hud-btn that JS toggles with
   the `hidden` attribute needs it beaten explicitly — author CSS wins over
   the browser's built-in [hidden]{display:none} regardless of specificity.
   See the standing note in CLAUDE.md; this has shipped as a real bug twice. */
.hud-btn[hidden]{display:none}
.hud-btn svg{width:var(--hud-icon);height:var(--hud-icon)}
.hud-btn:hover{transform:translateY(-2px)}   /* colour on hover: see the data-hue block below */
.hud-btn:active{transform:translateY(0)}
.hud-btn:focus-visible{outline:2px solid var(--dust);outline-offset:2px}

/* The button's name, PERMANENTLY, under the icon.
   It used to be a tooltip that appeared on hover and was switched off entirely
   under (hover:none) — which meant that on a phone, the entire HUD was six
   unlabelled glyphs and the only way to learn what any of them did was to
   press it and find out. A caption costs eight pixels of rail and removes the
   guessing. One word each, because the rail is narrow and two words collide
   with the next button down. */
.hud-btn::after{
  content:attr(data-label);position:absolute;
  top:calc(100% + 3px);left:50%;transform:translateX(-50%);
  white-space:nowrap;pointer-events:none;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:9px;letter-spacing:.14em;
  text-transform:uppercase;color:var(--muted);
  transition:color .18s ease;
}
.hud-btn:hover::after, .hud-btn:focus-visible::after{color:var(--btn-hue, var(--accent))}

/* The rail needs to make room for the captions now that they're always there. */
.hud-rail{gap:22px}

/* ---- each button's own colour ----
   Six identical grey glyphs are six things you have to read. A hue per button
   makes them findable by shape AND colour, and the hues come from the epoch
   variables rather than new hex, so a palette cosmetic recolours the whole HUD
   for free and the light theme is already handled.

   The resting state stays quiet — a tinted icon on the panel's own background,
   not a coloured chip — because six saturated buttons over the tree would
   compete with the nodes, which are the thing you're supposed to be looking
   at. The colour comes up on hover and focus. */
.hud-btn[data-hue="1"]{--btn-hue:var(--epoch-1)}
.hud-btn[data-hue="2"]{--btn-hue:var(--epoch-2)}
.hud-btn[data-hue="3"]{--btn-hue:var(--epoch-3)}
.hud-btn[data-hue="4"]{--btn-hue:var(--epoch-4)}
.hud-btn[data-hue="5"]{--btn-hue:var(--epoch-5)}
.hud-btn[data-hue="6"]{--btn-hue:var(--epoch-6)}
.hud-btn[data-hue="7"]{--btn-hue:var(--epoch-7)}
.hud-btn{color:color-mix(in srgb, var(--btn-hue, var(--dust)) 62%, var(--dust))}
.hud-btn:hover{
  border-color:var(--btn-hue, var(--accent));
  color:var(--btn-hue, var(--accent));
  box-shadow:0 6px 18px -10px var(--btn-hue, var(--accent));
}

/* Signed in, the profile button wears your initial instead of a stock person
   glyph — the same pip the friends list and the tree markers already use, so
   you recognise yourself by the same mark everywhere. */
.hud-avatar{
  position:absolute;inset:0;display:none;place-content:center;
  border-radius:inherit;
  background:color-mix(in srgb, var(--btn-hue, var(--accent)) 22%, transparent);
  color:var(--btn-hue, var(--accent));
  font-family:"Source Serif 4",serif;   /* a letter, so the display face, not the numeric one */
  font-size:calc(var(--hud-btn) * .42);font-weight:700;line-height:1;
}
.hud-avatar:not([hidden]){display:grid}

.guide-toggle{font-family:"Source Serif 4",serif;font-size:20px;font-weight:600;line-height:1}

/* ---------- the affordable compass ----------
   The tree is far bigger than the screen, so "there is something you can
   afford, it's just off to the left" was completely invisible — you had to
   go hunting for it. This rides the edge of the free space between the two
   HUDs, points at it, names it, and flies you there when tapped.
   `display:none` in the base rule + the :not([hidden]) rule below — see the
   standing note in CLAUDE.md about hidden-toggled elements. */
.afford-compass{
  position:fixed;z-index:6;display:none;
  align-items:center;gap:9px;
  transform:translate(-50%,-50%);
  padding:9px 15px 9px 11px;border-radius:22px;cursor:pointer;
  background:var(--void-2);border:1px solid var(--state-affordable);
  color:var(--state-affordable);font:inherit;
  box-shadow:0 0 22px -8px var(--state-affordable);
  animation:compass-nudge 1.8s ease-in-out infinite;
}
.afford-compass:not([hidden]){display:flex}
.afford-compass:focus-visible{outline:2px solid var(--dust);outline-offset:3px}
@keyframes compass-nudge{50%{box-shadow:0 0 32px -4px var(--state-affordable)}}
/* Only the arrow rotates — the label stays upright and readable at every angle. */
.afford-compass svg{
  width:16px;height:16px;flex:0 0 auto;
  transform:rotate(var(--compass-angle,0deg));
}
.afford-compass-name{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.06em;
  color:var(--dust);white-space:nowrap;max-width:40vw;overflow:hidden;text-overflow:ellipsis;
}

/* Sound on: the two arcs show, no slash. Sound off: the arcs go and the slash
   appears. Both states are drawn inside the icon's own viewBox (see
   index.html), so the slash sits on the speaker at the right angle whatever
   size the button is — the previous version was a CSS bar positioned against
   the BUTTON, which could never line up with the glyph inside it. */
.sound-toggle .mute-slash{opacity:0}
.sound-toggle.muted{color:var(--muted)}
.sound-toggle.muted .wave-on{opacity:0}
.sound-toggle.muted .mute-slash{opacity:1}

/* ---------- bottom HUD: hint above the core, stacked in flow so the
   core's responsive size can never overlap the hint above it ---------- */
.bottom-hud{
  position:fixed;left:50%;bottom:0;transform:translateX(-50%);
  z-index:5;
  display:flex;flex-direction:column;align-items:center;gap:20px;
  padding-bottom:calc(env(safe-area-inset-bottom) + 24px);
  transition:transform .28s ease;   /* matches the sheet's own slide, so they move together */
}

/* ---- the core stays live while a panel is open ----
   The detail sheet is bottom-anchored and lands exactly on the core, so the
   bottom HUD lifts by the panel's measured height (--sheet-lift, set in
   render.js) and sits above the sheet's scrim but below the panel itself.
   The drawer is a side panel, so it only needs the z-index — on a narrow
   screen the drawer is 86vw and still covers the core, which is fine: the
   drawer is somewhere you dip into, the sheet is where you sit and spend. */
/* The raised z-index is conditional on the core actually CLEARING the panel
   (see liftBottomHud). It's 22 against the panel's 21, so when there isn't
   room to lift the core clear, leaving it raised would draw it straight over
   the sheet's text and controls — which is exactly the bug this replaced.
   Without the class the core keeps its resting place and the sheet covers it,
   which is the correct fallback on a screen too short for both. */
body.sheet-open.sheet-core-clear .bottom-hud{
  z-index:22;                        /* sheet scrim is 20, sheet panel 21 */
  transform:translateX(-50%) translateY(calc(-1 * var(--sheet-lift, 0px)));
}
body.drawer-open .bottom-hud{z-index:25}   /* drawer scrim is 24 */

/* Under the sheet the two hint lines are just noise stacked on the panel —
   the core is the only thing that needs to be reachable up there. */
body.sheet-open .first-buy-hint{display:none}

.first-buy-hint{
  position:relative;pointer-events:auto;
  font-size:13px;line-height:1.65;color:var(--dust);text-align:center;max-width:min(360px,84vw);
  background:var(--void-2);border:1px solid var(--accent);border-radius:12px;
  padding:14px 34px 14px 18px;
  box-shadow:0 0 20px -8px var(--accent);
}
.first-buy-hint:not([hidden]){display:block}
.first-buy-hint-close{
  position:absolute;top:4px;right:6px;background:none;border:0;color:var(--muted);
  font-size:16px;line-height:1;cursor:pointer;padding:4px;
}
.first-buy-hint-close:hover{color:var(--dust)}

/* ---------- the core (signature element) — floats bottom-center over the tree ---------- */
.core{
  position:relative;
  width:clamp(104px,26vw,150px);height:clamp(104px,26vw,150px);
  border-radius:50%;
  border:1px solid var(--line);
  /* FIXED amber, not the epoch colour.

     It used to key off --accent so the core changed as the universe aged, and
     that was a lovely idea until Epoch 10: The Long Dark and Evaporation are
     deliberately the dimmest colours in the palette, so the one control a
     player must find turned into a black disc on a dark screen. A button
     cannot borrow its legibility from a variable that is allowed to go dark.

     So the body is always the warm gold of the dust it produces — the only
     genuinely warm thing on a cool purple screen, which is exactly what a
     primary action should be. The epoch signal did not disappear: it moved to
     the halo, which still reads --accent and rings the core in the colour of
     the age. Colour still tells you when you are; it no longer decides whether
     you can see the button.

     Layered rather than one ramp, because a single gradient reads as a flat
     disc with a coloured middle. These do what a lit sphere does: a specular
     highlight, a hot body, and a dark limb where the surface turns away. */
  background:
    radial-gradient(circle at 50% 36%, #fffdf5f0 0%, #fff8e100 30%),
    radial-gradient(circle at 50% 42%,
      #ffe9a8 0%,
      #ffd479 16%,
      #f5a637 34%,
      #b85f18 62%,
      #4a220e 84%,
      #1d0f12 100%);
  box-shadow:
    inset 0 -10px 22px -10px #00000099,     /* the limb, turning away */
    inset 0 6px 18px -8px #ffffff33;        /* the lit shoulder */
  cursor:pointer;overflow:hidden;
  /* Warm light, always. Sized so it reads as a source rather than a glow
     effect pasted behind a circle. */
  filter:drop-shadow(0 0 30px #f5a63780) drop-shadow(0 0 70px #f5a63740);
  transition:transform .08s ease, box-shadow 1.2s ease, background 1.2s ease;
  -webkit-tap-highlight-color:transparent;
}
.core:active{transform:scale(.955)}
.core:focus-visible{outline:2px solid var(--dust);outline-offset:6px}
.core-label{
  position:absolute;inset:0;display:grid;place-content:center;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.18em;
  /* Dark on the bright body, which is the readable way round now that the core
     is always light. It used to be dark text on whatever the epoch colour
     happened to be, which failed on the pale epochs and the dark ones alike. */
  text-transform:uppercase;color:#3d1e05;text-shadow:0 1px 0 #ffd47966;
}
/* The halo is where the epoch went. It rings the amber body in the colour of
   the current age, so the core still says WHEN you are without the body's
   legibility depending on it. Two pixels wider than before, because it is now
   carrying a signal rather than decorating one. */
.halo{
  position:absolute;inset:-4px;border-radius:50%;
  border:2px solid var(--accent);opacity:.62;
  animation:breathe 4s ease-in-out infinite;
}
@keyframes breathe{50%{transform:scale(1.07);opacity:.15}}

/* the core's one big moment: fired once per epoch transition, on top of the constant breathing halo */
.core.epoch-pulse{animation:epoch-pulse 1.2s ease-out}
/* Each frame restates the two inset shadows that make the core a sphere.
   box-shadow is a single property: animating only the outer glow would drop
   the limb and the lit shoulder for the length of the transition, and the core
   would flatten into a disc at exactly the moment it is meant to be the most
   dramatic thing on screen. The colour is --accent, so the flare is the new
   epoch's colour arriving over the amber body. */
@keyframes epoch-pulse{
  0%{
    box-shadow:inset 0 -10px 22px -10px #00000099, inset 0 6px 18px -8px #ffffff33,
      0 0 60px -12px var(--accent);
  }
  35%{
    box-shadow:inset 0 -10px 22px -10px #00000099, inset 0 6px 18px -8px #ffffff33,
      0 0 140px 20px var(--accent);
    transform:scale(1.18);
  }
  100%{
    box-shadow:inset 0 -10px 22px -10px #00000099, inset 0 6px 18px -8px #ffffff33,
      0 0 60px -12px var(--accent);
    transform:scale(1);
  }
}

.ripple{
  position:absolute;left:50%;top:50%;width:12px;height:12px;
  margin:-6px 0 0 -6px;border-radius:50%;border:1px solid var(--dust);
  transform:rotate(var(--ripple-rot, 0deg));
  animation:ripple .6s ease-out forwards;pointer-events:none;
}
@keyframes ripple{to{transform:rotate(var(--ripple-rot, 0deg)) scale(13);opacity:0}}
.ripple-auto{opacity:.5}

.pop{
  position:absolute;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:14px;
  color:var(--dust);pointer-events:none;animation:pop .8s ease-out forwards;
}
@keyframes pop{to{transform:translateY(-46px) translateX(var(--pop-drift, 0px));opacity:0}}
.pop-auto{font-size:11px;color:var(--muted)}

/* ---------- shop ---------- */
.shop-head{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.22em;
  text-transform:uppercase;color:var(--muted);
  border-bottom:1px solid var(--line);padding-bottom:10px;margin-bottom:8px;
}
/* The drawer's sections used to butt straight into each other — the global
   `*{margin:0}` reset means nothing separates them unless it's said here. */
.drawer section + section{margin-top:30px}
.gen{
  width:100%;display:grid;grid-template-columns:1fr auto;gap:4px 12px;
  align-items:center;text-align:left;
  background:transparent;border:0;border-bottom:1px solid var(--line);
  color:inherit;font:inherit;padding:14px 4px;cursor:pointer;
  transition:background .15s ease;
}
.gen:hover:not(:disabled){background:color-mix(in srgb, var(--dust) 4%, transparent)}
.gen:disabled{opacity:.38;cursor:not-allowed}
.gen:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.gen-name{font-weight:500}
.gen-owned{color:var(--muted);font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:13px}
.gen-sub{grid-column:1;font-size:12px;color:var(--muted)}
.gen-cost{
  grid-column:2;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:13px;
  color:var(--accent);
}
.gen:disabled .gen-cost{color:var(--muted)}

/* ---------- ambient dust motes — pure CSS, drift slowly behind the tree ---------- */
.motes{position:fixed;inset:0;z-index:1;overflow:hidden;pointer-events:none}
.mote{
  position:absolute;
  width:var(--mote-size,3px);height:var(--mote-size,3px);border-radius:50%;
  background:var(--accent);opacity:0;
  transition:width 2s ease, height 2s ease;
  animation:mote-drift linear infinite;
}
@keyframes mote-drift{
  0%{opacity:0;transform:translateY(0)}
  8%{opacity:var(--mote-opacity,.5)}
  92%{opacity:var(--mote-opacity,.5)}
  100%{opacity:0;transform:translateY(-70vh)}
}

/* ---------- the tree — the whole screen ---------- */
.tree-viewport{
  position:fixed;inset:0;z-index:2;
  overflow:hidden;   /* the canvas is moved by transform now, not native scroll */
  background:transparent;
  touch-action:none;   /* we handle pan/pinch ourselves — don't let the browser scroll/zoom underneath it */
  cursor:grab;
  /* Everything map-side — murals, connectors, nodes, every label — dissolves
     under the HUD rather than sliding behind it. --hud-cover is the HUD's own
     height, measured in js/render.js; the first stop keeps the map out of the
     band where the HUD's scrim is solid, and the ramp finishes exactly where
     the HUD does, so the two fades are one fade. 0px, the default, is a
     no-op mask that leaves the map fully visible. */
  /* NO `--hud-cover:0px` fallback on this element. js/render.js writes the
     measurement to <body> and it arrives here by inheritance — and a
     declaration on the element itself BEATS an inherited value, so a local
     default silently pinned this to zero and the mask never engaged at all.
     The fallback belongs in the var() below, where it is only reached when
     nothing has been written yet. */
  /* The rule is simply "the map begins below the HUD". A ramp that finished AT
     the HUD's lower edge still left labels at 80% opacity sitting in the last
     line of the readout — the HUD's ink runs all the way to its edge, so the
     map has to be gone by there, not merely on its way out. The 70px is the
     softening below it, and it is what stops the fade reading as a line ruled
     across the sky. */
  -webkit-mask-image:linear-gradient(to bottom,
    transparent 0, transparent var(--hud-cover, 0px), #000 calc(var(--hud-cover, 0px) + 70px));
  mask-image:linear-gradient(to bottom,
    transparent 0, transparent var(--hud-cover, 0px), #000 calc(var(--hud-cover, 0px) + 70px));
}
.tree-viewport.dragging{cursor:grabbing}
.tree-viewport.easing .tree-canvas{transition:transform .5s cubic-bezier(.2,.7,.3,1)}
.tree-canvas{
  position:relative;
  transform-origin:0 0;
}
/* will-change ONLY while a gesture is in flight, never at rest.
   A permanent `will-change:transform` pins the canvas to its own compositor
   layer, which the browser rasterises once and then rescales as a bitmap —
   so zooming in magnified stale pixels and every label and connector went
   soft. Scoping it to the gesture keeps the pan/pinch cheap and lets the
   browser re-rasterise at the final scale the moment you let go, which is
   when you are actually reading the tree. */
.tree-viewport.dragging .tree-canvas,
.tree-viewport.zooming .tree-canvas,
.tree-viewport.easing .tree-canvas{will-change:transform}
.tree-lines,.sky-lines{position:absolute;top:0;left:0;overflow:visible;pointer-events:none}

/* connector base state: dim, requirement not yet met */
.tree-lines path{
  fill:none;stroke:var(--line);stroke-width:2.5;
  /* Same reason as the node shadow: a 2px line at --line disappeared entirely
     over a bright mural. The dark outline underneath is what makes it hold. */
  filter:drop-shadow(0 0 3px #000000cc);
  transition:stroke 1s ease, stroke-dashoffset 1s ease-out;
}
/* lit: this connector's parent node is owned — the "circuit energising" state.
   The dash pattern + slow offset animation reads as current flowing downstream. */
.tree-lines path.lit{
  stroke:var(--node-color, var(--accent));
  stroke-width:3;
  stroke-dasharray:3 9;
  animation:connector-flow 1.2s linear infinite;
}
@keyframes connector-flow{to{stroke-dashoffset:-24}}

/* Node construction: the button itself stays an unclipped, unstyled box so
   its focus outline and its owned-count badge can sit OUTSIDE the silhouette.
   The visible node is two stacked pseudo-elements sharing one clip-path:
     ::before = the full shape, painted in the state colour  -> reads as the ring
     ::after  = the same shape inset 2.5px, painted in the fill -> the interior
   Clipping the button directly would have eaten both the badge and the
   keyboard focus ring, which is the whole reason for the pseudo-element pair. */
.tree-node{
  position:absolute;top:0;left:0;
  width:58px;height:58px;   /* fallback only — the real size is set inline from the node's rate */
  /* --node-zoom is written once per transform (see onTransform in render.js)
     and counter-scales every node together, so zooming out never turns the map
     into specks. It multiplies the centring translate rather than replacing
     it: the coordinate is still the node's centre. */
  transform:translate(-50%,-50%) scale(var(--node-zoom, 1));
  background:transparent;
  border:0;
  color:var(--epoch-color, var(--muted));   /* the glyph, via currentColor */
  display:grid;place-content:center;
  cursor:pointer;padding:0;
  transition:transform .12s ease, color .4s ease, opacity .4s ease;
  /* Every node now sits on a photograph rather than on flat dark space, and a
     thin ring on a bright nebula is invisible. This is the separation: a hard
     dark shadow directly under the silhouette, so the node reads as an object
     on top of the image rather than a shape cut out of it. */
  filter:drop-shadow(0 1px 2px #000000e6) drop-shadow(0 4px 14px #000000a6);
}
.tree-node::before{
  content:"";position:absolute;inset:0;z-index:0;
  clip-path:var(--shape, circle(50%));
  background:var(--state-color, var(--line));
  transition:background .4s ease, filter .3s ease;
}
.tree-node::after{
  /* 3.5px, up from 2.5: the ring is the node's whole state signal and it was
     losing to the imagery behind it. */
  content:"";position:absolute;inset:3.5px;z-index:1;
  clip-path:var(--shape, circle(50%));
  /* Lit from above, not filled flat.

     Half the map is photographs of real objects with light falling on them,
     and next to one a flat disc of --node-fill read as a piece of interface
     rather than a thing in the same sky — which is what "some have shapes and
     some have images" is describing. The gradient costs nothing and puts the
     two halves in the same world. */
  background:
    radial-gradient(circle at 50% 30%,
      color-mix(in srgb, var(--state-color, var(--epoch-color)) 26%, transparent) 0%,
      transparent 66%),
    var(--node-fill, var(--void));
  transition:background .4s ease;
}

/* The pool of light a photographed node stands in, given to a glyph node too.
   One language across the map: everything on it glows in its own state colour,
   whether the thing inside the glow is a picture or a mark. Locked nodes are
   excluded — a padlock that glows is advertising something you cannot have. */
.tree-node:not(.locked):not(.has-photo){
  filter:
    drop-shadow(0 0 8px color-mix(in srgb, var(--state-color, var(--epoch-color)) 38%, transparent))
    drop-shadow(0 1px 2px #000000e6)
    drop-shadow(0 4px 14px #000000a6);
}
.tree-node.keystone::after{inset:3.5px}
.tree-node svg{width:var(--icon-size,26px);height:var(--icon-size,26px);position:relative;z-index:2}
.tree-node-owned{z-index:3}
/* Keeps --node-zoom in the transform. Without it, hovering a node while zoomed
   out snapped it back to its uncounter-scaled size — it grew on hover at full
   zoom and SHRANK on hover when zoomed out. */
.tree-node:hover{transform:translate(-50%,-50%) scale(calc(var(--node-zoom, 1) * 1.08))}
.tree-node:focus-visible{outline:2px solid var(--dust);outline-offset:3px}

/* One silhouette per epoch. Shape carries epoch identity independently of
   colour, so the eras stay distinguishable for a colourblind player — and it
   gives the tree a rhythm as you climb it.
   Keyed off data-epoch rather than a class so the legend in the guide can
   reuse the exact same shapes without duplicating the polygons. */
[data-epoch="1"]{--shape:circle(50%)}
[data-epoch="2"]{--shape:polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)}
[data-epoch="3"]{--shape:polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)}
[data-epoch="4"]{--shape:polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)}
[data-epoch="5"]{--shape:inset(0 round 24%)}
[data-epoch="6"]{--shape:polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)}
[data-epoch="7"]{--shape:polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)}
/* 8 Divergence — a shape that splits, for the epoch where the tree does. */
[data-epoch="8"]{--shape:polygon(50% 0%, 100% 32%, 78% 100%, 50% 62%, 22% 100%, 0% 32%)}

/* the legend's mini-nodes: same two-layer construction, smaller */
.legend-node{
  position:relative;width:30px;height:30px;flex:0 0 auto;
  color:var(--epoch-color, var(--muted));
}
.legend-node::before{
  content:"";position:absolute;inset:0;
  clip-path:var(--shape, circle(50%));
  background:var(--state-color, var(--line));
}
.legend-node::after{
  content:"";position:absolute;inset:2px;
  clip-path:var(--shape, circle(50%));
  /* The same lighting the real nodes take — a legend that doesn't look like
     the thing it explains is worse than no legend. */
  background:
    radial-gradient(circle at 50% 30%,
      color-mix(in srgb, var(--state-color, var(--epoch-color)) 26%, transparent) 0%,
      transparent 66%),
    var(--node-fill, var(--void));
}
.legend-node svg{width:15px;height:15px;position:absolute;inset:0;margin:auto;z-index:2}
.legend-node.owned{--state-color:var(--state-owned);--node-fill:color-mix(in srgb, var(--epoch-color) 18%, var(--void-2))}
.legend-node.affordable{--state-color:var(--state-affordable)}
/* mirrors the thick affordable ring on the real node, so the key matches the tree */
.legend-node.affordable::after{inset:3.5px}
.legend-node.available{--state-color:var(--state-available)}
.legend-node.locked{--state-color:var(--line);--epoch-color:var(--line);opacity:.55}

/* keystone size is folded into nodeSize() now — this class only carries the
   thicker interior inset and the idle animation below */

/* Keystones are the seven moments that close an epoch, so their glyph
   breathes — slowly and out of phase with the state pulses on the ring, so
   the two readings don't beat against each other. Locked keystones stay
   still: an unknown shouldn't advertise itself as important.
   The global prefers-reduced-motion rule disables this with everything else. */
.tree-node.keystone:not(.locked) svg{
  transform-origin:50% 50%;
  animation:keystone-idle 5.5s ease-in-out infinite;
}
@keyframes keystone-idle{50%{transform:scale(1.13);opacity:.8}}

/* ---- the four states: ring colour + shaped glow ----
   The glow is filter: drop-shadow(), not box-shadow — box-shadow is painted
   outside the clip-path and gets cut away, so it would draw a rectangular
   halo around a hexagon. drop-shadow follows the silhouette. */
/* Smaller and dimmer than the road you can actually walk — they were full-size
   padlocks carrying the same weight as the frontier. One block, not two: the
   quieting was added later in the sheet and the two rules disagreed about
   opacity, with the later one silently winning. */
.tree-node.locked{
  --state-color:var(--line);
  --epoch-color:var(--line);      /* an unknown must not reveal its epoch colour */
  cursor:not-allowed;
  opacity:.5;
  transform:translate(-50%,-50%) scale(calc(var(--node-zoom, 1) * .72));
  filter:drop-shadow(0 1px 2px #000000cc);
}
/* Cancels the hover GROW on a locked node without cancelling the counter-scale
   or the locked shrink — writing the bare translate here put a locked node back
   to full size the moment the pointer crossed it, and only when zoomed out,
   which is exactly the kind of flicker that reads as a rendering bug. */
.tree-node.locked:hover{transform:translate(-50%,-50%) scale(calc(var(--node-zoom, 1) * .72))}

.tree-node.available{--state-color:var(--state-available)}
.tree-node.available::before{animation:pulse-available 2.6s ease-in-out infinite}
@keyframes pulse-available{50%{filter:drop-shadow(0 0 7px var(--state-available-soft))}}

.tree-node.affordable{--state-color:var(--state-affordable)}
.tree-node.affordable::before{
  filter:drop-shadow(0 0 5px var(--state-affordable));
  animation:pulse-affordable 1.6s ease-in-out infinite;
}
@keyframes pulse-affordable{50%{filter:drop-shadow(0 0 13px var(--state-affordable))}}

/* A second, NON-COLOUR cue for the one pair that colour alone separated.
   available vs affordable differed by ring hue and pulse rate — and
   prefers-reduced-motion switches both pulses off, leaving a colourblind
   player with hue as the only signal, on the single most important
   distinction in the game. Affordable now also carries a visibly thicker
   ring, which survives reduced motion and greyscale alike. */
.tree-node.affordable::after{inset:5px}
.tree-node.keystone.affordable::after{inset:6.5px}

/* The beacon: the first affordable node before you own anything at all.
   Louder than affordable on purpose — it's onboarding, and it retires itself
   the moment the first node is bought. */
.tree-node.beacon::before{
  animation:pulse-beacon 1.1s ease-in-out infinite;
}
@keyframes pulse-beacon{
  50%{filter:drop-shadow(0 0 22px var(--state-affordable)) drop-shadow(0 0 8px var(--dust))}
}
/* Reduced motion kills the animation, so the beacon needs a still form too. */
@media (prefers-reduced-motion:reduce){
  .tree-node.beacon::before{filter:drop-shadow(0 0 18px var(--state-affordable))}
}

/* Owned: the ring goes quiet and the interior picks up a wash of the epoch's
   colour, so a finished node reads as filled-in rather than as loud. */
.tree-node.owned{
  --state-color:var(--state-owned);
  --node-fill:color-mix(in srgb, var(--epoch-color) 18%, var(--void-2));
}
.tree-node.owned::before{animation:owned-breathe 3.2s ease-in-out infinite}
@keyframes owned-breathe{50%{filter:drop-shadow(0 0 8px var(--state-owned))}}
.tree-node.keystone.owned::before{filter:drop-shadow(0 0 6px var(--epoch-color))}

/* The purchase burst — fired from burstNode() on every buy. Deliberately on
   the button, not the clipped pseudo-elements, so it can grow past the node's
   silhouette instead of being cut to it. */
.node-burst{
  position:absolute;inset:-2px;border-radius:50%;pointer-events:none;
  border:2px solid var(--epoch-color, var(--accent));
  animation:node-burst .55s cubic-bezier(.2,.7,.3,1) forwards;
}
@keyframes node-burst{
  from{opacity:.9;transform:scale(1)}
  to{opacity:0;transform:scale(2.5)}
}

/* How many you own.

   Moved off the TOP-right corner. A framed node has a ring there for it to sit
   against; a photographed one has the picture, and the badge landed in the
   middle of the artwork -- the dark chip on the corner of Matter Wins and The
   Disc. Bottom-right puts it where a caption goes, clear of the picture and of
   the name above it.

   Counter-scaled, too. Sitting inside the node it inherited BOTH the map scale
   and the node's own counter-scale, so it shrank as you stepped back while
   every label around it held its size: at 0.5 zoom an 18px pill drew at 6px
   and the number in it was a coloured speck. --badge-zoom is that exact sum
   undone -- a different figure from --label-zoom, which the labels use, because
   the labels are canvas siblings and carry only the map's scale. */
.tree-node-owned{
  position:absolute;bottom:-5px;right:-6px;min-width:19px;height:19px;padding:0 5px;
  background:var(--epoch-color, var(--state-owned));color:var(--void);
  border-radius:100px;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;font-weight:600;
  display:grid;place-content:center;
  box-shadow:0 0 0 1.5px color-mix(in srgb, var(--void) 85%, transparent),
             0 2px 6px #000000b3;
  transform:scale(var(--badge-zoom, 1));
  transform-origin:100% 100%;
}

.tree-node-cost{
  position:absolute;top:0;left:0;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11.5px;color:var(--dust);
  white-space:nowrap;pointer-events:none;
  padding:2px 9px;
  background:radial-gradient(ellipse 56% 58% at 50% 50%,
    color-mix(in srgb, var(--void) 80%, transparent) 0%,
    transparent 100%);
  /* The mirror of .tree-node-name's transform, pointing down. --stack is the
     extra screen-pixel offset a second line needs to clear the first. */
  transform:translate(-50%,0)
            translateY(calc(var(--node-r, 29px) * (var(--node-zoom, 1) - 1)))
            scale(var(--label-zoom, 1))
            translateY(calc(9px + var(--stack, 0px)));
  transform-origin:50% 0;
  /* Detail, and the first thing given up on the way out — see DETAIL_FADE_TO.
     Prices are only useful close enough to act on one. */
  opacity:var(--detail-fade, 1);
  transition:opacity .18s linear;
}
/* The epoch rule and its name, behind the nodes. height:0 + border-top so the
   element IS the line; the label rides above it, so no background is needed to
   punch a hole through the rule. */
/* An epoch as a band across the tiers. The bands tile the canvas edge to edge
   with no gaps, so every part of the map belongs to an era.

   --epoch-image is set per epoch (see the epoch backdrops block further down)
   and is a real photograph. It sits under a heavy scrim: the picture is
   atmosphere, and a node's ring and price have to stay the brightest things in
   their own neighbourhood. Bands with no image just show the tint.

   Nothing here takes pointer events — the tree behind it must stay draggable
   straight through. */
/* The band is now just a region: a name, and the space an epoch owns. The
   photograph is the .epoch-mural inside it — see renderEpochRegions() for why
   the picture cannot live on this element. */
.epoch-region{
  position:absolute;pointer-events:none;
  display:flex;align-items:flex-start;justify-content:center;
}

/* The photograph. Sized to its OWN aspect ratio (--epoch-aspect, set per epoch
   below) and taller than the band it sits in, so the image fills this element
   exactly — which is what lets the mask feather the picture itself rather than
   the empty margins around it. Overflowing is deliberate: neighbouring epochs
   overlap and blend, and the tree floats over one continuous field rather than
   a stack of framed pictures. */
.epoch-mural{
  position:absolute;left:50%;top:50%;
  /* --mx/--my/--ms/--mr are set per epoch below. Every mural used to sit dead
     centre, which stacked all ten into a column down the middle of the map
     while the nodes sat out at the edges — the art and the thing you are
     reading ended up in different places. Each one now has its own offset,
     scale and tilt.
     
     Scattered, but AUTHORED, not random: a map that rearranges itself between
     sessions is a map nobody can learn. */
  transform:translate(-50%, -50%) translate(var(--mx, 0%), var(--my, 0%))
            scale(var(--ms, 1)) rotate(var(--mr, 0deg));
  height:210%;aspect-ratio:var(--epoch-aspect, 1.6);
  background-image:var(--epoch-image, none);
  background-size:cover;background-position:center;
  opacity:.28;
  -webkit-mask-image:radial-gradient(ellipse 50% 50% at 50% 50%, #000 0%, #000a 40%, transparent 70%);
  mask-image:radial-gradient(ellipse 50% 50% at 50% 50%, #000 0%, #000a 40%, transparent 70%);
}
.epoch-region-label{position:relative;z-index:1}
/* An era name is a TERRITORY label, and territory is not labelled the way a
   point is.

   Both were wearing the same dark pill, so "3 · FIRST LIGHT" and "First Stars"
   read as two items of the same kind sitting near each other, and the map lost
   its levels. An atlas has settled this for a couple of centuries: a city gets
   a small, dark, tight label right next to its dot; an ocean gets a large,
   faint, widely-tracked one lying across the water with no box at all. You
   never confuse the two, and the ocean's name never competes with the city's.

   So: no plate, no edge, roughly twice the size, four times the tracking, and
   faint enough that a node name crossing it always wins. */
.epoch-region-label{
  transform:translateY(-50%);padding:0;white-space:nowrap;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:16px;letter-spacing:.46em;text-transform:uppercase;
  /* The indent balances the tracking: letter-spacing is added AFTER the last
     glyph too, so a centred tracked line sits half a space left of true. */
  text-indent:.46em;
  color:color-mix(in srgb, var(--epoch-color) 62%, var(--muted));
  opacity:.56;
  background:none;
  /* Enough of a halo to survive a bright mural, not enough to become a box. */
  text-shadow:0 0 10px var(--void), 0 0 20px var(--void), 0 1px 3px #000;
  transform:translateY(-50%) scale(var(--label-zoom, 1));
}

/* A locked node's name sits dimmer than a reached one — legible enough to read
   the road ahead, quiet enough that the frontier still reads as the bright
   edge. Prose, not a number, so it is not a tabular-nums rule. */
.tree-node-name.locked{color:var(--dust);opacity:calc(var(--label-fade, 1) * .82)}
/* What would open it, in place of the price. Wraps rather than running off
   sideways: "Needs Baryogenesis and Recombination" is longer than any cost. */
.tree-node-req{
  position:absolute;top:0;left:0;
  width:max-content;max-width:128px;text-align:center;
  padding:3px 9px;
  font-size:10.5px;line-height:1.35;color:var(--dust);
  pointer-events:none;
  background:radial-gradient(ellipse 60% 60% at 50% 50%,
    color-mix(in srgb, var(--void) 80%, transparent) 0%,
    transparent 100%);
  text-shadow:0 0 4px #000, 0 0 9px #000000d9;
  /* The mirror of .tree-node-name's transform, pointing down. --stack is the
     extra screen-pixel offset a second line needs to clear the first. */
  transform:translate(-50%,0)
            translateY(calc(var(--node-r, 29px) * (var(--node-zoom, 1) - 1)))
            scale(var(--label-zoom, 1))
            translateY(calc(9px + var(--stack, 0px)));
  transform-origin:50% 0;
  opacity:calc(var(--detail-fade, 1) * .85);
  transition:opacity .18s linear;
}

/* milestone multiplier chip, under the cost */
.tree-node-mult{
  position:absolute;top:0;left:0;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  color:var(--epoch-color,var(--accent));
  white-space:nowrap;pointer-events:none;
  /* The mirror of .tree-node-name's transform, pointing down. --stack is the
     extra screen-pixel offset a second line needs to clear the first. */
  transform:translate(-50%,0)
            translateY(calc(var(--node-r, 29px) * (var(--node-zoom, 1) - 1)))
            scale(var(--label-zoom, 1))
            translateY(calc(9px + var(--stack, 0px)));
  transform-origin:50% 0;
  opacity:var(--detail-fade, 1);
  transition:opacity .18s linear;
}

/* Owned nodes glow harder the further up the milestone ladder they are, so
   depth is visible from across the tree and not only inside the sheet. */
.tree-node.owned[data-mult="2"]::before{filter:drop-shadow(0 0 7px var(--epoch-color))}
.tree-node.owned[data-mult="4"]::before{filter:drop-shadow(0 0 11px var(--epoch-color))}
.tree-node.owned[data-mult="8"]::before{filter:drop-shadow(0 0 16px var(--epoch-color))}
.tree-node.owned[data-mult="16"]::before{
  animation:owned-breathe-max 2.6s ease-in-out infinite;
}
@keyframes owned-breathe-max{
  0%,100%{filter:drop-shadow(0 0 20px var(--epoch-color))}
  50%{filter:drop-shadow(0 0 32px var(--epoch-color))}
}

/* ---- node labels ----
   Two things keep these readable, and both are needed:

   z-index — every node is its own stacking context (they're all
   `transform:translate(-50%,-50%)`), and labels used to sit at `auto` in DOM
   order, so any node rendered LATER painted straight over the label of a node
   rendered earlier. That's the "text is covered" bug; a whole label could
   disappear behind a neighbour.

   the halo — the tree is dense, and a label will sometimes cross a connector
   or a neighbour whatever the spacing is. A ring of the background colour
   behind the glyphs keeps it legible when that happens, without needing a
   solid plate that would clutter the map. */
.tree-node-name, .tree-node-cost, .tree-node-mult, .tree-node-req{
  z-index:2;
  text-shadow:0 0 4px var(--void), 0 0 8px var(--void), 0 0 12px var(--void);
}

.tree-node-name{
  position:absolute;top:0;left:0;
  /* max-content, not a fixed width: the plate below has to hug the name, and a
     fixed 112px box would draw a 112px pill behind the word "Leaking". */
  width:max-content;max-width:126px;
  padding:3px 11px;
  font-size:13px;font-weight:600;line-height:1.3;text-align:center;color:var(--text);
  /* A SCRIM, not a plate.

     The problem is real: these sit over photographs of nebulae, and a text
     shadow only bites when what is behind it is darker than the text, so over
     the bright fronts in Star Forges or The Fog Clears the name vanished into
     the picture. But the first answer was an opaque pill with a hard edge, and
     twenty of those turned the map into a list of captions that happened to
     have pictures attached: the pill was wider than the artwork it labelled.

     This is the same darkening with no edge to it. Densest under the glyphs,
     gone before it reaches anything else, so it reads as the sky being a
     little deeper just there rather than as an object sitting on top of it. */
  background:radial-gradient(ellipse 58% 62% at 50% 50%,
    color-mix(in srgb, var(--void) 88%, transparent) 0%,
    color-mix(in srgb, var(--void) 66%, transparent) 48%,
    transparent 100%);
  text-shadow:0 0 4px #000, 0 0 9px #000000d9, 0 1px 2px #000;
  pointer-events:none;
  /* Names are the last thing to go. See LABEL_FADE_TO in js/render.js. */
  opacity:var(--label-fade, 1);
  transition:opacity .18s linear;
  /* --label-zoom, not 1/--node-zoom. The old value cancelled the node's
     counter-scale and left the text at the raw map scale, so an 11px name drew
     at 6px the moment you stepped back — visible, and unreadable. This one
     holds the text at its authored size on screen instead. Written once per
     transform by onTransform(). */
  /* Read right to left:
       translateY(-13px)  the gap, INSIDE the scale, so it survives as 13
                          screen pixels at every zoom instead of closing up
       scale(--label-zoom) holds the text at its authored size on screen
       translateY(...)    the node counter-scales when you zoom out and paints
                          out past the anchor this label was placed against;
                          this is exactly that overshoot, pushed back
       translate(-50%,-100%)  sit the text's bottom centre on the anchor */
  transform:translate(-50%,-100%)
            translateY(calc(var(--node-r, 29px) * (1 - var(--node-zoom, 1))))
            scale(var(--label-zoom, 1))
            translateY(-15px);
  transform-origin:50% 100%;
}

.tree-node.discover{animation:discover-node .9s ease-out}
@keyframes discover-node{
  0%{opacity:0;transform:translate(-50%,-50%) scale(.4)}
  55%{opacity:1;transform:translate(-50%,-50%) scale(1.2)}
  100%{transform:translate(-50%,-50%) scale(1)}
}


/* ---------- detail sheet ---------- */
.sheet-backdrop{
  position:fixed;inset:0;background:color-mix(in srgb, var(--scrim) 67%, transparent);opacity:0;pointer-events:none;
  transition:opacity .2s ease;z-index:20;
}
.sheet-panel{
  position:fixed;left:0;right:0;bottom:0;z-index:21;
  max-width:620px;margin:0 auto;
  max-height:85vh;max-height:85dvh;overflow-y:auto;   /* stays usable on short/landscape phone screens */
  background:var(--void-2);border-top:1px solid var(--line);border-radius:16px 16px 0 0;
  padding:22px 22px calc(22px + env(safe-area-inset-bottom));
  transform:translateY(100%);transition:transform .28s ease;
}

/* The reason the sheet is open, pinned to the bottom of it.

   A node with a photograph, six stats, three refinements and a milestone bar
   is taller than 85dvh on most screens, and the panel scrolls — so the Buy
   button, which is the entire point of opening the thing, sat below the fold
   with nothing on screen to say it was there. It reads as a sheet that has
   been cut off.

   Sticky rather than fixed: on a short sheet it simply sits where it always
   sat, and it only starts holding the floor once there is something to scroll
   past it. The scrim above it is what makes the content read as continuing
   underneath rather than ending. */
/* The panel hands its bottom padding to the footer, so the footer's own edge
   IS the bottom of the sheet. A sticky element's `bottom:0` resolves against
   the scrollport — the panel's padding box — so any padding left on the panel
   becomes a strip below the footer that the scrolling content slides through.
   That strip is what made a pinned footer still look like a cut-off sheet. */
.sheet-panel.has-actions{padding-bottom:0}
.sheet-actions{
  position:sticky;bottom:0;z-index:2;
  margin:14px -22px 0;
  padding:14px 22px calc(18px + env(safe-area-inset-bottom));
  background:var(--void-2);
  border-top:1px solid var(--line);
}
/* The content has to read as continuing underneath the footer rather than
   ending at it — otherwise a scrollable sheet looks like a cut-off one, which
   is the problem this is here to solve. */
.sheet-actions::before{
  content:"";position:absolute;left:0;right:0;bottom:100%;height:22px;
  background:linear-gradient(transparent, var(--void-2));
  pointer-events:none;
}
.sheet-actions .qty-select{margin-bottom:8px}
/* Keyed to the CLASS, not to #detailSheet. Both sheets are built from the same
   markup and both get `open` from JS, but only the timeline's id was ever
   written here — so the Firmament's sheet was left parked at translateY(100%)
   with an invisible backdrop, and tapping an object in the Firmament appeared
   to do nothing at all. Anything that styles one sheet must style `.sheet`. */
.sheet.open .sheet-backdrop{opacity:1;pointer-events:auto}
.sheet.open .sheet-panel{transform:translateY(0)}

/* The double-tap window, held open.
   A tap opens the sheet on the next frame, and the moment it opens the
   backdrop covers the whole screen — so the SECOND tap of a double-tap hit the
   backdrop instead of the node, elementFromPoint found no .tree-node, and the
   buy never happened. The window to beat it was one animation frame, which is
   why double-tap-to-buy felt like it needed inhuman speed.

   For the length of the double-tap window the backdrop stays untouchable and
   taps fall through to the map underneath. Tap-outside-to-close is only
   deferred by that window, never lost. */
.sheet.open.tap-through .sheet-backdrop{pointer-events:none}

/* The Firmament is its own full-screen layer at z-index 22, so a sheet opened
   OVER it has to clear that; the timeline's sheet only ever has to clear the
   tree. Still below the Supernova prompt at 31, which outranks every panel.

   Keyed to `body.firmament-open`, NOT to a sheet's id. It was `#skySheet` and
   that is precisely how the second Firmament sheet (the constellation panel)
   shipped invisible: its panel sat at 21, under the view at 22, so tapping a
   constellation hub appeared to do nothing at all. Same failure the id-keyed
   `.sheet.open` rules caused once before. Written this way, any sheet that
   opens over the Firmament clears it automatically and the next one added
   can't inherit the bug. */
body.firmament-open .sheet-backdrop{z-index:23}
body.firmament-open .sheet-panel{z-index:24}

.sheet-close{
  position:absolute;top:14px;right:16px;background:none;border:0;color:var(--muted);
  font-size:20px;line-height:1;cursor:pointer;padding:4px;
}
.sheet-close:hover{color:var(--dust)}

.sheet-icon{
  width:56px;height:56px;border-radius:50%;display:grid;place-content:center;
  background:var(--void);border:2px solid var(--node-color, var(--accent));color:var(--node-color, var(--accent));
  margin-bottom:10px;
}
.sheet-icon svg{width:26px;height:26px}
.sheet-epoch{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.18em;text-transform:uppercase;
  color:var(--node-color, var(--muted));margin-bottom:5px;
}
.sheet-name{font-family:"Source Serif 4",serif;font-weight:600;font-size:22px;line-height:1.25;margin-bottom:6px}
.sheet-when{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;color:var(--muted);
  margin-bottom:16px;
}
.sheet-flavour{font-size:13.5px;line-height:1.7;color:var(--dust);margin-bottom:22px}

.sheet-stats{display:flex;gap:16px 28px;margin-bottom:24px;flex-wrap:wrap}
.sheet-stat-label{
  display:block;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.15em;
  text-transform:uppercase;color:var(--muted);margin-bottom:5px;
}
.sheet-stats > div{font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:15px}

/* quantity selector — sits directly above the buy button it modifies */
/* Five options now, and on a narrow phone five in a row is four too many
   characters wide — so it wraps rather than squeezing each button below a
   readable width. */
.qty-select{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.qty-btn{
  flex:1 1 56px;padding:8px 4px;border-radius:6px;cursor:pointer;
  background:transparent;border:1px solid var(--line);color:var(--muted);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:12px;
  transition:border-color .15s ease, color .15s ease, background .15s ease;
}
.qty-btn:hover{border-color:var(--dust);color:var(--dust)}
.qty-btn.selected{
  border-color:var(--node-color, var(--accent));color:var(--node-color, var(--accent));
  background:color-mix(in srgb, var(--dust) 5%, transparent);
}
.qty-btn:focus-visible{outline:2px solid var(--dust);outline-offset:2px}
/* The milestone button on a node that has reached every milestone. */
.qty-btn:disabled{opacity:.35;cursor:not-allowed}
.qty-btn:disabled:hover{border-color:var(--line);color:var(--muted)}

.sheet-buy{
  width:100%;padding:14px;border-radius:8px;border:0;cursor:pointer;
  background:var(--node-color, var(--accent));color:var(--void);
  font:inherit;font-weight:500;font-size:14px;
  transition:opacity .15s ease;
}
.sheet-buy:disabled{opacity:.4;cursor:not-allowed}
/* The one buy in the game that closes a door behind it. Outlined rather than
   filled, so it reads as a decision to weigh instead of the usual one-tap
   purchase — the same button, deliberately not the same invitation. */
.sheet-buy.is-choice{
  background:none;color:var(--epoch-8);
  border:1px solid var(--epoch-8);
  box-shadow:inset 0 0 24px -12px var(--epoch-8);
}

.sheet-milestone{margin-bottom:18px}
.sheet-milestone-label{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;color:var(--muted);margin-bottom:6px;
}
.sheet-milestone-bar{height:6px;border-radius:3px;background:var(--line);overflow:hidden}
.sheet-milestone-fill{height:100%;background:var(--node-color, var(--accent));transition:width .3s ease}

/* ---------- prestige ---------- */
.collapse-btn{
  width:100%;margin-top:10px;padding:14px;border-radius:8px;
  background:transparent;border:1px solid var(--line);color:var(--muted);
  font:inherit;font-size:13px;cursor:pointer;
  transition:border-color .15s ease, color .15s ease;
}
.collapse-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
.collapse-btn:disabled{opacity:.4;cursor:not-allowed}

/* ---------- seed pairs ---------- */
.seed-pair{margin-bottom:14px}
.seed-sides{display:flex;gap:8px;margin-top:6px}
.seed-side{
  flex:1 1 0;min-width:0;min-height:var(--tap);
  text-align:left;padding:8px 10px;border-radius:8px;cursor:pointer;
  background:transparent;border:1px solid var(--line);color:var(--muted);
  display:flex;flex-direction:column;gap:2px;
  transition:border-color .15s ease, color .15s ease, background .15s ease;
}
.seed-side:hover:not(:disabled){border-color:var(--dust);color:var(--dust)}
.seed-side.selected{
  border-color:var(--accent);color:var(--accent);
  background:color-mix(in srgb, var(--accent) 8%, transparent);
}
/* Disabled means "locked in for this universe", not "broken" — so it stays
   readable rather than fading to nothing. The selected side is never disabled,
   so what you HAVE is always at full contrast. */
.seed-side:disabled{opacity:.55;cursor:not-allowed}
.seed-side-name{font-family:"Source Serif 4",serif;font-weight:600;font-size:12px}
.seed-side-effect{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;line-height:1.35;opacity:.85;
}
.seed-respec-note{
  margin-top:5px;font-size:10px;line-height:1.4;color:var(--muted);opacity:.75;
}

.carry-block{
  display:none;
  max-width:420px;margin:22px auto 0;text-align:left;
  border:1px solid var(--line);border-radius:12px;padding:14px 16px;
}
.carry-block:not([hidden]){display:block}
.carry-head{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.2em;
  text-transform:uppercase;color:var(--accent);margin-bottom:6px;
}
.carry-note{font-size:12px;line-height:1.5;color:var(--muted);margin-bottom:12px}
.carry-options{display:flex;flex-wrap:wrap;gap:8px}
.carry-option{
  display:flex;align-items:center;gap:7px;
  background:transparent;border:1px solid var(--line);border-radius:20px;
  color:var(--muted);font:inherit;font-size:12px;padding:7px 13px;cursor:pointer;
  transition:border-color .15s ease, color .15s ease, background .15s ease;
}
.carry-option:hover{border-color:var(--dust);color:var(--dust)}
.carry-option.selected{
  border-color:var(--accent);color:var(--accent);
  background:var(--accent-soft);
}
.carry-option-mult{font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px}

.collapse-actions{display:flex;gap:12px;justify-content:center;margin-top:22px;flex-wrap:wrap}
.collapse-cancel, .collapse-confirm-btn{
  padding:12px 22px;border-radius:8px;font:inherit;font-size:13px;cursor:pointer;
}
.collapse-cancel{background:transparent;border:1px solid var(--line);color:var(--muted)}
.collapse-cancel:hover{border-color:var(--dust);color:var(--dust)}
.collapse-confirm-btn{background:var(--accent);border:0;color:var(--void);font-weight:500}

/* ---------- the codex ---------- */
.codex-head{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.22em;
  text-transform:uppercase;color:var(--epoch-color,var(--muted));
  border-bottom:1px solid var(--line);
  padding-bottom:7px;margin:24px 0 14px;
}
.codex-head:first-child{margin-top:0}

.codex-entry{display:flex;gap:14px;align-items:flex-start;margin-bottom:18px}
.codex-entry:last-child{margin-bottom:0}
.codex-icon{
  flex:0 0 auto;width:40px;height:40px;border-radius:50%;
  display:grid;place-content:center;
  background:var(--void);
  border:1px solid var(--epoch-color,var(--line));
  color:var(--epoch-color,var(--muted));
}
.codex-icon.locked{border-color:var(--line);color:var(--line)}
.codex-icon svg{width:20px;height:20px}
.codex-text{display:flex;flex-direction:column;gap:3px;min-width:0}
.codex-name{font-family:"Source Serif 4",serif;font-weight:600;font-size:15px;color:var(--dust)}
.codex-when{font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;color:var(--epoch-color,var(--muted))}
.codex-flavour{font-size:13px;line-height:1.55;color:var(--dust)}

/* Undiscovered entries dim via COLOUR, not opacity. Opacity multiplies
   through to the text and dropped these below readable contrast — the point
   is "not found yet", not "illegible". */
.codex-entry:not(.unlocked) .codex-name{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-weight:400;font-size:12px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--muted);
}

/* ---------- achievements: compact list ---------- */
.record-row{
  display:flex;gap:10px;align-items:flex-start;
  padding:10px 4px;border-bottom:1px solid var(--line);
}
/* Locked rows dim by colour rather than opacity, same reasoning as above. */
.record-row:not(.unlocked) .record-name{color:var(--muted)}
.record-row:not(.unlocked) .record-desc{color:var(--muted)}

.drawer-link{
  width:100%;text-align:left;background:transparent;
  border:1px solid var(--line);border-radius:8px;color:var(--dust);
  font:inherit;font-size:13px;padding:11px 13px;cursor:pointer;
  transition:border-color .15s ease, color .15s ease;
}
.drawer-link:hover{border-color:var(--accent);color:var(--accent)}

/* The contact block.

   Built as a card with an accent edge rather than as loose text in the drawer,
   because it was the one section a player reaches for when something has gone
   wrong with money and it read as the faintest thing on the screen. The body
   copy is --dust, not --muted: this is the block that must survive a glance
   from somebody who is already annoyed.

   The address is rendered as text rather than only as a link, so it can be
   read, written down or copied even when nothing on the machine can open a
   mailto:. */
.contact-card{
  border:1px solid var(--line);
  border-left:3px solid var(--accent);
  border-radius:10px;
  background:color-mix(in srgb, var(--accent) 6%, var(--void));
  padding:14px 15px;
}
.contact-note{font-size:13px;line-height:1.6;color:var(--dust);margin:0 0 12px}

/* A chip, so the thing you actually need is the loudest thing in the block. */
.contact-address{
  display:inline-block;
  font-family:"Space Grotesk",sans-serif;
  color:var(--accent);
  background:color-mix(in srgb, var(--accent) 13%, transparent);
  border:1px solid var(--accent-soft);
  border-radius:5px;
  padding:1px 7px;
  word-break:break-all;
}

/* Filled, not outlined. Every other .drawer-link is a navigation choice; this
   one is the action the section exists for and should not look identical to
   "Open the codex". */
.contact-copy{
  width:100%;
  background:var(--accent);color:var(--void);
  border:1px solid var(--accent);border-radius:8px;
  font:inherit;font-size:13px;font-weight:600;
  padding:11px 13px;cursor:pointer;
  transition:filter .15s ease;
}
.contact-copy:hover{filter:brightness(1.12)}
.contact-copy.copied{background:var(--state-affordable);border-color:var(--state-affordable)}

.contact-alt{margin:10px 0 0;font-size:11.5px;color:var(--muted)}
.contact-alt a{color:var(--muted);text-decoration:underline;text-underline-offset:2px}
.contact-alt a:hover{color:var(--accent)}
.record-check{
  flex:0 0 auto;width:18px;text-align:center;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);
}
.record-row.unlocked .record-check{color:var(--state-affordable)}
.record-body{display:flex;flex-direction:column;gap:2px}
.record-name{font-weight:500;font-size:13px}
.record-desc{font-size:12px;color:var(--muted);line-height:1.4}

/* ---------- drawer: everything that isn't the tree or the core ---------- */
.drawer-backdrop{
  position:fixed;inset:0;z-index:24;background:color-mix(in srgb, var(--scrim) 67%, transparent);
  opacity:0;pointer-events:none;transition:opacity .25s ease;
}
.drawer-backdrop:not([hidden]){display:block}
.drawer-backdrop.show{opacity:1;pointer-events:auto}

.drawer{
  position:fixed;top:0;right:0;bottom:0;z-index:25;
  /* Was a flat min(340px, 86vw): 340px on a 2560px monitor and 340px on a
     laptop, so the one place the game puts dense two-column content — seed
     pairs, records, achievements — was cramped at every size above a phone.
     Now it grows with the viewport and stops before it becomes a wall of
     text. clamp() rather than a media query so there is no width at which it
     jumps. */
  width:clamp(320px, 34vw, 620px);
  max-width:92vw;
  background:var(--void-2);border-left:1px solid var(--line);
  padding:calc(env(safe-area-inset-top) + 20px) 20px 20px;
  overflow-y:auto;
  transform:translateX(100%);transition:transform .3s ease;
}
.drawer:not([hidden]){display:block}
.drawer.show{transform:translateX(0)}

.drawer-close{
  position:absolute;top:14px;right:16px;background:none;border:0;color:var(--muted);
  font-size:20px;line-height:1;cursor:pointer;padding:4px;
}
.drawer-close:hover{color:var(--dust)}

.drawer-footer{
  margin-top:30px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;
  font-size:11px;color:var(--muted);font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
}
.drawer-footer button{
  background:none;border:1px solid var(--line);color:var(--muted);
  font:inherit;padding:5px 10px;border-radius:4px;cursor:pointer;
}
.drawer-footer button:hover{border-color:var(--accent);color:var(--accent)}

.save-hint{font-size:12px;line-height:1.5;color:var(--muted);margin-bottom:12px}
.save-actions{display:flex;gap:10px;flex-wrap:wrap}
.save-actions button{
  background:none;border:1px solid var(--line);color:var(--dust);
  font:inherit;font-size:12px;padding:8px 12px;border-radius:6px;cursor:pointer;
}
.save-actions button:hover{border-color:var(--accent);color:var(--accent)}

.toast{
  position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 14px);transform:translateX(-50%);
  /* 29, and it was 15, which meant the toast lost to almost everything that
     fires one. The shop, the codex and the guide are all .guide-overlay at 28
     and the drawer is 25, so "Gems added", "Not enough gems" and every message
     about a payment rendered UNDERNEATH the panel that caused them. A purchase
     looked silent because it was.

     Above the panels, below the Supernova prompt (31) and the epoch transition
     (30), because those two are moments the game has earned and a passing
     remark must not cover them. */
  z-index:29;
  max-width:calc(100vw - 32px);
  background:var(--void-2);border:1px solid var(--line);
  padding:10px 16px;border-radius:6px;font-size:13px;
  opacity:0;transition:opacity .4s ease;pointer-events:none;
  text-align:center;
}
.toast.show{opacity:1}

/* ---------- the update banner ----------
   Sits above the toast rather than beside it: a toast is a passing remark and
   this one waits for an answer. It's the only surface in the game with its own
   buttons at the bottom of the screen, so it stops short of the core's space
   and is dismissible — an update nobody wants shouldn't camp on the thumb.

   display:none in the base rule, display:flex only once JS drops the `hidden`
   attribute. Author CSS beats the browser's built-in [hidden]{display:none}
   regardless of specificity, so a bare display here would make this render
   permanently — the trap documented in CLAUDE.md. */
.update-banner{
  position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 68px);
  transform:translateX(-50%);
  z-index:30;                       /* over the toast (29), under the Supernova prompt (31) */
  display:none;
  align-items:center;gap:10px;
  max-width:calc(100vw - 24px);
  padding:8px 8px 8px 16px;border-radius:10px;
  background:var(--void-2);
  border:1px solid color-mix(in srgb, var(--epoch-7) 55%, var(--line));
  box-shadow:0 0 18px -4px color-mix(in srgb, var(--epoch-7) 45%, transparent);
  opacity:0;transition:opacity .3s ease;
}
.update-banner:not([hidden]){display:flex}
.update-banner.show{opacity:1}
.update-banner-text{font-size:13px;color:var(--dust)}
.update-banner-btn{
  background:none;border:1px solid var(--epoch-7);color:var(--epoch-7);
  font:inherit;font-size:12px;padding:0 14px;border-radius:6px;cursor:pointer;
  transition:background .18s ease, color .18s ease;
}
.update-banner-btn:hover{background:var(--epoch-7);color:var(--void)}
.update-banner-close{
  background:none;border:none;color:var(--muted);
  font-size:20px;line-height:1;cursor:pointer;
  display:grid;place-content:center;padding:0;
}
.update-banner-close:hover{color:var(--dust)}

/* Narrow phones: the text wraps rather than pushing the buttons off screen. */
@media (max-width:420px){
  .update-banner{gap:8px;padding-left:12px}
  .update-banner-text{font-size:12px}
}

/* ---------- the guide: what everything on screen means ---------- */
.guide-overlay{
  position:fixed;inset:0;z-index:28;display:none;
  background:color-mix(in srgb, var(--scrim) 90%, transparent);
  opacity:0;transition:opacity .25s ease;
  overflow-y:auto;
  padding:clamp(16px,5vw,40px) clamp(12px,4vw,24px);
}
.guide-overlay:not([hidden]){display:block}
.guide-overlay.show{opacity:1}

.guide-panel{
  /* 560px fixed was set when the only long panel was the guide. The pass
     track is two columns and forty rows, and the shop is a grid — both were
     reading a page-width column on a screen with room for three. Grows with
     the viewport, capped where a line of prose stops being comfortable to
     read. Panels that want more say so themselves (.pass-panel below). */
  max-width:min(720px, 92vw);margin:0 auto;
  background:var(--void-2);border:1px solid var(--line);border-radius:16px;
  padding:clamp(20px,5vw,30px);position:relative;
}
.guide-title{
  font-family:"Source Serif 4",serif;font-weight:600;font-size:clamp(22px,6vw,28px);
  color:var(--accent);margin-bottom:6px;
}
.guide-intro{font-size:13px;line-height:1.6;color:var(--dust);margin-bottom:22px}
.guide-section{margin-bottom:22px}
.guide-section:last-child{margin-bottom:0}
.guide-head{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.22em;
  text-transform:uppercase;color:var(--muted);
  border-bottom:1px solid var(--line);padding-bottom:7px;margin-bottom:12px;
}
.guide-row{display:flex;gap:12px;align-items:flex-start;margin-bottom:12px}
.guide-row:last-child{margin-bottom:0}
.guide-row-body{display:flex;flex-direction:column;gap:2px;min-width:0}
.guide-row-name{font-weight:500;font-size:13px}
.guide-row-desc{font-size:12px;line-height:1.45;color:var(--muted)}
.guide-key{
  flex:0 0 auto;min-width:74px;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;color:var(--accent);
  padding-top:1px;
}
.guide-close{
  position:absolute;top:12px;right:14px;background:none;border:0;color:var(--muted);
  font-size:22px;line-height:1;cursor:pointer;padding:4px;
}
.guide-close:hover{color:var(--dust)}

/* ---------- THE FIRMAMENT ----------
   A fitted view, not a navigable canvas: objects sit at percentage
   coordinates and the field simply reflows into whatever space it has. That
   is why there is no pan/zoom here and no fixed canvas size. */
.light-readout{
  display:none;
  margin-top:4px;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--epoch-7);
}
.light-readout:not([hidden]){display:block}

/* The way in: a portal standing on the timeline, not a button in the chrome.
   It has to read as a place rather than a control, so it's bigger than a node
   and carries its own slow rotating rings. */
/* One construction, two doors. Each portal is tinted with the colour of the
   place it LEADS TO, not the place it stands in — so the cyan one on the
   timeline reads as "the Firmament is through here", and the warm one in the
   Firmament reads as the way home. */
/* A BLACK HOLE, and it is meant to stop you.

   This element is the only way to the second half of the game, and every
   earlier attempt at it failed the same way: it was drawn at roughly the size
   of a node, so it joined the set of things that are nodes. A ring, an event
   horizon, a dark centre, a vertical tear — all of them read as "one of these
   is different" rather than "what IS that".

   So it is not node-scale. At 304px it is nearly three times the largest
   keystone on the timeline, and it is built the way the real thing looks: an
   accretion disc bent all the way around the hole by its own gravity, so the
   far side arcs OVER the top instead of disappearing behind it, a photon ring
   at the last stable orbit, and a horizon in the middle that is genuinely
   black rather than dark.

   Five layers, all CSS. `--portal-color` tints the light by DESTINATION, so
   the one on the timeline burns the Firmament's cyan and the one in the
   Firmament burns the timeline's amber — the colour tells you where the door
   goes before you read the label. */
.tree-portal,.sky-portal{
  position:absolute;top:0;left:0;width:304px;height:304px;
  /* Counter-scales with the nodes so it never becomes the smallest thing on
     the map at exactly the zoom where you are looking for it. */
  transform:translate(-50%,-50%) scale(var(--node-zoom, 1));
  border:0;padding:0;cursor:pointer;color:var(--portal-color);
  background:none;
  display:grid;place-content:center;
  transition:transform .4s cubic-bezier(.2,.7,.3,1), filter .4s ease;
  filter:drop-shadow(0 0 40px color-mix(in srgb, var(--portal-color) 40%, transparent));
}
.tree-portal{--portal-color:var(--epoch-7)}
.sky-portal{--portal-color:var(--accent)}
/* One construction, three tints. Each portal wears the colour of the place it
   LEADS TO, never the place it stands in — the rule is in NOTES.md and this is
   the third instance of it. The Fine Structure is the deep end of the epoch
   scale, so it wears the last one. */
.structure-portal{--portal-color:var(--epoch-10)}

/* 1. The lensed sky. Light from behind the hole dragged around it into a
      smeared halo — a conic gradient turning slowly, blurred past any hard
      edge, so it reads as distortion rather than as a ring you could touch. */
.portal-lens{
  position:absolute;inset:0;border-radius:50%;
  background:conic-gradient(from 0deg,
    transparent 0deg,
    color-mix(in srgb, var(--portal-color) 26%, transparent) 40deg,
    transparent 90deg,
    color-mix(in srgb, var(--portal-color) 16%, transparent) 170deg,
    transparent 220deg,
    color-mix(in srgb, var(--portal-color) 30%, transparent) 300deg,
    transparent 360deg);
  filter:blur(26px);
  opacity:.85;
  animation:portal-lens-turn 46s linear infinite;
  pointer-events:none;
}
@keyframes portal-lens-turn{to{transform:rotate(360deg)}}

/* The layer order IS the physics, so it is stated rather than left to document
   order:  0 halo · 1 far side of the disc, over the top · 2 photon ring ·
   3 horizon · 4 near side of the disc, in FRONT of the hole's lower edge.

   That last one is what sells it. A ring drawn entirely behind a black circle
   is a planet with rings; a ring that passes over the top AND in front at the
   bottom is light being bent, and the eye knows the difference straight away
   even when it cannot say why. */
.portal-lens{z-index:0}
.portal-arc{z-index:1}
.tree-portal::before,.sky-portal::before{z-index:2}
.tree-portal::after,.sky-portal::after{z-index:3}
.portal-disc{z-index:4}

/* The near side. Sits low so it crosses the bottom of the shadow rather than
   bisecting it, and overhangs both edges so the same band reads as the disc's
   outer wings on either side. */
.portal-disc{
  position:absolute;left:-19%;right:-19%;top:53%;height:22%;
  border-radius:50%;
  background:
    radial-gradient(ellipse 50% 50% at 50% 50%,
      #fff 0%,
      color-mix(in srgb, var(--portal-color) 95%, #fff) 22%,
      color-mix(in srgb, var(--portal-color) 82%, transparent) 46%,
      color-mix(in srgb, var(--portal-color) 30%, transparent) 70%,
      transparent 90%);
  filter:blur(1.6px);
  /* Thinned where it crosses the shadow: that slice is the disc seen most
     nearly edge-on, and at full brightness it painted a bar across the hole. */
  -webkit-mask-image:radial-gradient(ellipse 20% 130% at 50% 50%, transparent 0%, #000 72%);
  mask-image:radial-gradient(ellipse 20% 130% at 50% 50%, transparent 0%, #000 72%);
  animation:portal-beam 9s ease-in-out infinite;
  pointer-events:none;
}
/* Relativistic beaming — the side turning toward you is brighter. A slow swing
   rather than a spin: an edge-on disc rotating in the page reads as a wheel. */
@keyframes portal-beam{
  0%,100%{opacity:.8;transform:translateX(-2%)}
  50%{opacity:1;transform:translateX(2%)}
}

/* 3. The far side of the disc, bent up and over the top. This is the detail
      that makes it read as a black hole rather than a planet with rings: the
      disc does not vanish behind the object, it wraps. Clipped to the upper
      half so the near side below is left to the flat disc above. */
.portal-arc{
  position:absolute;inset:13%;border-radius:50%;
  border:5px solid transparent;
  border-top-color:color-mix(in srgb, var(--portal-color) 92%, #fff);
  border-left-color:color-mix(in srgb, var(--portal-color) 65%, transparent);
  border-right-color:color-mix(in srgb, var(--portal-color) 65%, transparent);
  filter:blur(2px) drop-shadow(0 0 14px color-mix(in srgb, var(--portal-color) 60%, transparent));
  /* Faded away at the bottom, where the near side takes over. Left to complete
     the circle it is just a ring again and the wrap is lost. */
  -webkit-mask-image:linear-gradient(#000 44%, transparent 76%);
  mask-image:linear-gradient(#000 44%, transparent 76%);
  pointer-events:none;
}

/* 4. The photon ring — the last orbit light can hold before it falls in.
      Thin, brilliant, and the sharpest thing in the whole construction; every
      other layer is blurred so this one reads as the edge. */
.tree-portal::before,.sky-portal::before{
  content:"";position:absolute;inset:29%;border-radius:50%;
  box-shadow:
    0 0 0 1.5px color-mix(in srgb, var(--portal-color) 92%, #fff),
    0 0 22px 2px color-mix(in srgb, var(--portal-color) 70%, transparent),
    inset 0 0 26px 6px color-mix(in srgb, var(--portal-color) 45%, transparent);
  animation:portal-breathe 6s ease-in-out infinite;
}
@keyframes portal-breathe{50%{transform:scale(1.035)}}

/* 5. The horizon. Actually black — not --void, not a dark mix. It is the one
      thing on either map that emits nothing, and the contrast against the
      photon ring a pixel outside it is the whole effect. */
.tree-portal::after,.sky-portal::after{
  content:"";position:absolute;inset:30%;border-radius:50%;
  background:#000;
  pointer-events:none;
}

.tree-portal:hover,.sky-portal:hover{
  transform:translate(-50%,-50%) scale(calc(var(--node-zoom, 1) * 1.05));
  filter:drop-shadow(0 0 64px color-mix(in srgb, var(--portal-color) 70%, transparent));
}
.tree-portal:focus-visible,.sky-portal:focus-visible{
  outline:2px solid var(--dust);outline-offset:8px;border-radius:50%;
}

.portal-label{color:var(--epoch-7)}
.portal-label-back{width:130px;text-align:center}
.portal-label-back .sky-label-name{color:var(--accent)}
.portal-label-back .sky-label-dist{color:var(--muted)}

/* The core and both button rails belong to the timeline, not here — the
   Firmament has its own back button and its own header. */
/* The rails stay. They are the game's controls — sound, help, your profile,
   the shop — and none of them stop being available because you walked through
   a door; hiding them made the Firmament a place you had to leave before you
   could do anything. They have to clear the Firmament's own layer (22), the
   way the sheets already do.

   The top HUD and the core do NOT come with them: those read the timeline's
   own state — the epoch, the dust counter, the thing you tap — and none of it
   means anything out here. The Firmament states its own reach and Light in
   the readouts it already has. */
body.firmament-open .hud-rail{z-index:23}
body.firmament-open .bottom-hud,
body.firmament-open .hud-top{display:none}

/* Back belongs at the top of the View rail, with the rest of the controls that
   change what you are looking at — not floating on its own in the corner the
   rail already occupies. */
#firmamentClose{display:none}
body.firmament-open #firmamentClose{display:grid}

.firmament-view{
  position:fixed;inset:0;z-index:22;display:none;
  flex-direction:column;
  background:
    radial-gradient(1200px 800px at 50% 0%, var(--sky-glow) 0%, transparent 70%),
    radial-gradient(1px 1px at 18% 32%, #ffffff66, transparent),
    radial-gradient(1px 1px at 72% 18%, #ffffff55, transparent),
    radial-gradient(1.5px 1.5px at 44% 74%, #ffffff4a, transparent),
    radial-gradient(1px 1px at 87% 58%, #ffffff45, transparent),
    radial-gradient(1px 1px at 28% 88%, #ffffff40, transparent),
    var(--void);
  opacity:0;transition:opacity .26s ease;
}
/* No padding and no flex column any more: the map fills the view edge to edge
   and every readout floats over it absolutely. */
.firmament-view:not([hidden]){display:block}
.firmament-view.show{opacity:1}

.firmament-label{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.24em;
  text-transform:uppercase;color:var(--muted);
}
.firmament-reach{
  font-family:"Source Serif 4",serif;font-weight:600;
  font-size:clamp(20px,5.5vw,30px);color:var(--epoch-7);line-height:1.15;margin-top:2px;
}
.firmament-sub{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;color:var(--muted);
  margin-top:4px;letter-spacing:.08em;
}

/* ============================================================
   THE FIRMAMENT — one map, edge to edge
   ============================================================

   No panel, no card grid, no tab bar. The viewport IS the view and everything
   else floats over it, exactly the way the timeline works. Three kinds of
   thing share the sky: objects (which produce Light), stars (bought one at a
   time), and constellation hubs (the dial, once complete).

   Colours come from the epoch variables via --canvas-key/-deep/-glow, so this
   sits inside the game's palette and follows a palette cosmetic. The Firmament
   is a different PLACE, not a different product. */

/* Floating readouts. pointer-events:none throughout — they sit over a map you
   drag, and a readout that eats a pan is a readout that broke the map. */
.sky-readout{
  position:absolute;z-index:3;pointer-events:none;
  top:calc(env(safe-area-inset-top) + 14px);
}
/* Centred, and wide enough for the biggest number the game can produce. The
   two readouts used to be a centred one and a right-hand one; the right-hand
   corner is where the You rail lives, so the currency was both small and
   crowded. */
.sky-readout-top{
  left:50%;transform:translateX(-50%);text-align:center;
  max-width:min(72vw, 560px);
}
.sky-light-line{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  /* The same size as the timeline's dust counter, because it is the same kind
     of thing: the one number the whole place is denominated in. Matching them
     is what makes the two halves read as one game rather than two screens. */
  font-size:clamp(26px,8vw,42px);line-height:1.1;letter-spacing:-.02em;
  color:var(--canvas-glow);margin-top:10px;
  text-shadow:0 0 6px #000, 0 0 26px color-mix(in srgb, var(--canvas-glow) 55%, transparent);
}
.sky-light-sub{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  /* --muted at 10px over a starfield was barely there. These say what your
     Light is doing and how much of it is committed; if they cannot be read
     they may as well not be drawn. */
  font-size:12px;letter-spacing:.06em;color:var(--dust);margin-top:4px;
  text-shadow:0 0 4px #000, 0 0 10px #000000cc;
}
#firmamentRate{color:color-mix(in srgb, var(--canvas-glow) 70%, var(--dust))}

/* Bottom-left, clear of the top-left Back button and of the core's old space. */
.sky-actions{
  position:absolute;z-index:3;
  left:14px;bottom:calc(env(safe-area-inset-bottom) + 14px);
  display:flex;gap:8px;flex-wrap:wrap;
}

/* ---- stars ----
   A point of light, sized by its real apparent magnitude and coloured by its
   real spectral class. No ring, no fill, no badge: the star map's grammar is
   luminosity, and a star that looked like a timeline node would say the two
   places are the same place. */
.sky-star{
  position:absolute;transform:translate(-50%,-50%);
  padding:0;border:0;border-radius:50%;cursor:pointer;
  background:var(--star, var(--light-bright));
  opacity:.35;
  transition:opacity .3s ease, filter .3s ease, transform .18s ease;
}
.sky-star:hover{transform:translate(-50%,-50%) scale(1.25)}
.sky-star:focus-visible{outline:2px solid var(--dust);outline-offset:3px}
/* Unowned and unaffordable: a faint smudge, visibly out of reach. */
.sky-star.available{opacity:.22}
/* Affordable: it starts to shine, and pulses gently. */
.sky-star.affordable{
  opacity:.8;
  filter:drop-shadow(0 0 7px var(--star, var(--light-bright)));
  animation:star-ready 2.6s ease-in-out infinite;
}
@keyframes star-ready{50%{filter:drop-shadow(0 0 13px var(--star, var(--light-bright)))}}
/* Charted: full brightness, permanently. */
.sky-star.owned{
  opacity:1;
  filter:drop-shadow(0 0 10px var(--star, var(--light-bright)));
}

/* Distance tracks. Faint, still, and behind everything — an astrolabe's rings,
   not a decoration. Each sits at one object's own radius, so the spacing
   between them IS the log scale made visible. */
.sky-orbit{
  fill:none;
  stroke:color-mix(in srgb, var(--epoch-7) 13%, transparent);
  stroke-width:1;vector-effect:non-scaling-stroke;
  stroke-dasharray:2 10;
}
/* The rail. The one line the whole map hangs off, so it is the only thing here
   drawn solid — everything else is dashed or faint against it. */
.sky-spine{
  stroke:color-mix(in srgb, var(--epoch-7) 30%, transparent);
  stroke-width:2;vector-effect:non-scaling-stroke;stroke-linecap:round;
}
/* Distance gridlines crossing the rail, and the zone boundaries. Both sit UNDER
   the rail in weight: they are the grid it is drawn on, not more content. */
.sky-scale-ring{
  fill:none;
  stroke:color-mix(in srgb, var(--epoch-7) 16%, transparent);
  stroke-width:1;vector-effect:non-scaling-stroke;
  stroke-dasharray:2 6;
}
.sky-zone-edge{
  stroke:color-mix(in srgb, var(--epoch-7) 11%, transparent);
  stroke-width:1;vector-effect:non-scaling-stroke;
  stroke-dasharray:1 14;
}
.sky-zone-label{
  position:absolute;transform:translate(-50%,-50%);
  white-space:nowrap;pointer-events:none;
  font-family:"Source Serif 4",serif;font-weight:600;
  font-size:15px;letter-spacing:.3em;text-transform:uppercase;
  color:color-mix(in srgb, var(--epoch-7) 34%, var(--muted));
  opacity:.6;
}
.sky-scale-label{
  position:absolute;transform:translate(-50%,-50%);
  padding:0 8px;white-space:nowrap;pointer-events:none;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;
  color:color-mix(in srgb, var(--epoch-7) 42%, var(--muted));
  opacity:.75;
}

/* A label on the left half of the map flips to the other side of its node and
   right-aligns, so it reads outward instead of pointing back across the
   centre. translateX(-100%) is what actually moves it; `left` is already set
   to the node's inner edge by the renderer. */
.sky-label.flip, .star-label.flip{
  transform:translate(-100%, -50%);
  text-align:right;
}
.sky-label.flip{align-items:flex-end}
.star-label.flip{align-items:flex-end}

.star-label{
  position:absolute;transform:translateY(-50%);
  display:flex;flex-direction:column;gap:1px;pointer-events:none;
  opacity:.6;transition:opacity .3s ease;
}
.star-label.owned{opacity:1}
.star-label-name{
  font-family:"Source Serif 4",serif;font-weight:500;
  font-size:11px;color:var(--dust);white-space:nowrap;
}
.star-label-cost{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:9px;letter-spacing:.06em;color:var(--muted);white-space:nowrap;
}

/* ---- the figure lines ----
   Still, thin, and only drawn between two stars you actually own — so the
   shape assembles as you buy it and a finished constellation is a finished
   drawing. Deliberately NOT the timeline's animated flow. */
.star-line{
  stroke:color-mix(in srgb, var(--dust) 10%, transparent);
  stroke-width:1;vector-effect:non-scaling-stroke;
  stroke-dasharray:3 5;
  transition:stroke .5s ease;
}
.star-line.drawn{
  stroke:color-mix(in srgb, var(--canvas-key) 45%, transparent);
  stroke-dasharray:none;
}
.star-line.complete{stroke:color-mix(in srgb, var(--canvas-key) 75%, transparent)}
/* The tether from the object that opens a region — fainter than a real
   connector, because it's a route rather than a dependency you bought. */
.sky-tether{opacity:.3}

/* ---- the constellation hub ---- */
.sky-hub{
  position:absolute;transform:translate(-50%,-50%);
  display:flex;flex-direction:column;align-items:center;gap:2px;
  padding:8px 14px;border-radius:10px;cursor:pointer;
  background:color-mix(in srgb, var(--canvas-void) 72%, transparent);
  border:1px solid transparent;
  transition:border-color .3s ease, box-shadow .3s ease, opacity .3s ease;
}
.sky-hub:focus-visible{outline:2px solid var(--dust);outline-offset:3px}
.hub-name{
  font-family:"Source Serif 4",serif;font-weight:600;
  font-size:15px;color:var(--dust);white-space:nowrap;
}
.hub-state{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.08em;color:var(--muted);white-space:nowrap;
}
.sky-hub.locked{opacity:.4;cursor:default}
.sky-hub.partial{border-color:color-mix(in srgb, var(--line) 80%, transparent)}
.sky-hub.complete{border-color:color-mix(in srgb, var(--canvas-key) 55%, transparent)}
.sky-hub.complete .hub-state{color:var(--canvas-key)}
/* Lit: the constellation is running, and the hub says so from across the map. */
.sky-hub.lit{
  border-color:var(--canvas-key);
  box-shadow:0 0 26px -6px color-mix(in srgb, var(--canvas-key) 70%, transparent);
}
.sky-hub.lit .hub-state{color:var(--canvas-glow)}

/* ---- the constellation sheet ---- */
.socket-sub{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-top:3px;
}
.socket-effect{
  font-family:"Source Serif 4",serif;
  font-size:12.5px;line-height:1.5;color:var(--muted);margin:8px 0 0;
}
.socket-locked, .socket-no-socket{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;line-height:1.6;letter-spacing:.04em;color:var(--muted);margin-top:12px;
}
.socket-no-socket{color:var(--epoch-4);margin-top:0}

.socket-readout{
  margin-top:12px;padding:9px 11px;border-radius:8px;
  background:color-mix(in srgb, var(--canvas-void) 55%, transparent);
  border-left:2px solid var(--canvas-key);
  display:flex;flex-direction:column;gap:3px;
}
.socket-headline{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:15px;color:var(--canvas-key);
}
.socket-detail{
  font-family:"Source Serif 4",serif;
  font-size:11.5px;line-height:1.45;color:var(--muted);
}

.socket-alloc{display:none;margin-top:14px;flex-direction:column;gap:10px}
.socket-alloc:not([hidden]){display:flex}

/* The only range input in the game, so it's styled from scratch in both
   engines. Track in the Canvas ground, thumb in the Firmament's own colour. */
.socket-slider{
  -webkit-appearance:none;appearance:none;width:100%;background:none;cursor:pointer;
}
.socket-slider:disabled{opacity:.4;cursor:default}
.socket-slider::-webkit-slider-runnable-track{
  height:5px;border-radius:3px;
  background:linear-gradient(90deg, var(--canvas-deep), var(--canvas-glow));
}
.socket-slider::-moz-range-track{
  height:5px;border-radius:3px;
  background:linear-gradient(90deg, var(--canvas-deep), var(--canvas-glow));
}
.socket-slider::-webkit-slider-thumb{
  -webkit-appearance:none;appearance:none;
  width:20px;height:20px;border-radius:50%;margin-top:-7.5px;
  background:var(--canvas-key);border:2px solid var(--void-2);
  box-shadow:0 0 12px -2px var(--canvas-key);
}
.socket-slider::-moz-range-thumb{
  width:20px;height:20px;border-radius:50%;border:2px solid var(--void-2);
  background:var(--canvas-key);box-shadow:0 0 12px -2px var(--canvas-key);
}
.socket-slider:focus-visible{outline:2px solid var(--dust);outline-offset:4px}

.socket-amount{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--muted);text-align:center;
}
.socket-buttons{display:flex;gap:6px}
.socket-btn{
  flex:1 1 0;min-width:0;
  background:none;border:1px solid var(--line);color:var(--dust);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;border-radius:7px;cursor:pointer;
  transition:border-color .18s ease, color .18s ease;
}
.socket-btn:hover{border-color:var(--canvas-key);color:var(--canvas-key)}
.socket-btn-reset:hover{border-color:var(--epoch-4);color:var(--epoch-4)}

/* The Firmament's own tree viewport — same machinery as the timeline's, its
   own instance. Nodes reuse .tree-node wholesale (states, shapes, glows) with
   the epoch colour forced to Light, so there's one visual language across
   both places and only the orientation and palette differ. */
.sky-viewport{
  position:absolute;inset:0;overflow:hidden;
  touch-action:none;cursor:grab;
  /* The sky ends below the readout rather than sliding behind it — the same
     rule and the same shape of fade the timeline uses for its HUD. --sky-cover
     is measured in js/firmament.js; the fallback lives in the var() rather
     than on this element, because a declaration here would beat the inherited
     value and pin it to zero. That mistake cost a round on the timeline. */
  -webkit-mask-image:linear-gradient(to bottom,
    transparent 0, transparent var(--sky-cover, 0px), #000 calc(var(--sky-cover, 0px) + 70px));
  mask-image:linear-gradient(to bottom,
    transparent 0, transparent var(--sky-cover, 0px), #000 calc(var(--sky-cover, 0px) + 70px));
}
.sky-viewport.dragging{cursor:grabbing}
.sky-viewport.easing .sky-canvas{transition:transform .5s cubic-bezier(.2,.7,.3,1)}
/* The canvas carries NO background of its own, exactly like .tree-canvas. It is
   a coordinate space, not a surface.

   This is the whole reason the Firmament used to look like a page with a map
   sitting on it. A background here — even a soft-edged one — paints a
   SKY_W x SKY_H rectangle that pans and zooms with the content, so the sky had
   an edge, and past that edge was something else. Feathering the edge only made
   the box blurry; it was still a box. The atmosphere belongs to .firmament-view,
   which is position:fixed inset:0 — it IS the screen, so there is nothing for
   the sky to sit inside and no boundary to find by panning. */
.sky-canvas{
  position:relative;transform-origin:0 0;
}
/* Same reasoning as .tree-canvas above — crisp at rest, cheap while moving. */
.sky-viewport.dragging .sky-canvas,
.sky-viewport.zooming .sky-canvas,
.sky-viewport.easing .sky-canvas{will-change:transform}

/* Sightlines, not circuitry. The timeline's connectors animate like current
   flowing through a circuit; these are thin, still, dashed — the geometry of
   looking at something far away, not of energy moving. */
.sky-lines path{
  fill:none;stroke:var(--line);stroke-width:1.2;
  stroke-dasharray:1 7;stroke-linecap:round;
  transition:stroke .8s ease, opacity .8s ease;
  opacity:.5;
}
.sky-lines path.lit{stroke:var(--epoch-7);opacity:.85}

/* Objects are points of light. No ring-and-fill construction, no state
   badge — brightness carries both how far you've got and how much you own,
   which is the one thing a sky can say that a circuit can't. */
.sky-node{
  position:absolute;top:0;left:0;
  transform:translate(-50%,-50%);
  border:0;padding:0;background:transparent;cursor:pointer;
  color:var(--epoch-7);
  display:grid;place-content:center;
  transition:transform .12s ease, color .4s ease, filter .4s ease;
}

/* ---- celestial bodies ----
   An object is drawn as the thing it actually is, not as a glyph of it. Every
   body is built the same way and differs only in its surface:

     1. a base fill in the object's real colour (--base)
     2. a surface, per `kind`, from layered gradients
     3. limb darkening + a terminator, both from one inset shadow, with the
        light coming from the UPPER LEFT on every body — that consistency is
        what makes a circle read as a sphere instead of a disc
     4. a thin rim of --accent on the lit side, which is the atmosphere

   All CSS, so there are no binary assets, nothing to license, nothing to
   precache, and it stays sharp at any zoom. A node carrying `image:` gets a
   real photograph instead and skips all of this — same markup, same size. */
.sky-body{
  position:absolute;inset:6%;border-radius:50%;
  background-color:var(--base);
  background-repeat:no-repeat;
  z-index:2;
  /* A real terminator. The old -7px/-7px inset was a hint of shade in one
     corner; a lit sphere has a night side taking up most of its far half, and
     without that a body is a coloured disc with a highlight on it. Light comes
     from the upper left on every object here, so the shadow is thrown to the
     lower right and goes to black at the limb.

     In PX, not %. box-shadow offsets and blurs are lengths -- a percentage is
     invalid there, and the browser drops the whole declaration rather than the
     one bad value. A first pass at this used % and silently removed the
     terminator from every body in the Firmament. */
  box-shadow:
    inset -10px -10px 20px -4px rgba(0,0,0,.76),
    inset -3px -3px 9px rgba(0,0,0,.42),
    inset 7px 7px 14px -3px color-mix(in srgb, var(--accent) 45%, transparent);
}

/* The lit limb -- a bright crescent where the sun catches the edge, on the LIT
   SIDE ONLY.

   The version of this that ran all the way round was the thing making these
   read as stamped circles: a uniform bright rim is an outline, and an outline
   is what you draw around a token. A crescent is what a photograph of a sphere
   actually shows, and it tells you where the light is coming from, which a
   circle cannot do. */
/* Not on a star: a sun is lit from inside, so it has no lit side and no
   crescent. .body-star already opts out of the terminator above for the same
   reason, and this is the other half of that. */
.sky-body:not(.body-star):not([data-body="asteroid_belt"]):not([data-body="kuiper_belt"])::after{
  content:"";position:absolute;inset:-1px;border-radius:50%;
  background:linear-gradient(138deg,
    color-mix(in srgb, var(--accent) 85%, #fff) 0%,
    color-mix(in srgb, var(--accent) 40%, transparent) 22%,
    transparent 46%);
  -webkit-mask:radial-gradient(closest-side circle, transparent 82%, #000 90%);
  mask:radial-gradient(closest-side circle, transparent 82%, #000 90%);
  pointer-events:none;
}

/* Bodies WITH an atmosphere get one, and it reaches past the disc -- the
   honest reason some of these should not end at their own edge while others
   should. Venus and the gas giants are all atmosphere; Earth has a thin one;
   the Moon and Mercury have none at all and stay knife-sharp, because that is
   what they look like. */
.body-cloud::before, .body-gas::before,
.sky-body[data-body="earth"]::before, .sky-body[data-body="venus"]::before{
  content:"";position:absolute;inset:-9%;border-radius:50%;
  background:radial-gradient(closest-side circle,
    transparent 60%,
    color-mix(in srgb, var(--accent) 26%, transparent) 82%,
    transparent 100%);
  pointer-events:none;
}
/* contain and feathered, not cover. `cover` crops a body to a square and hands
   back a hard edge on all four sides — the same box the branch tiles were.
   Nothing ships with a photograph yet; this is here so the first one that does
   cannot arrive in a frame. */
.sky-body-image{
  background-size:contain;background-position:center;background-repeat:no-repeat;
  -webkit-mask-image:radial-gradient(closest-side circle at 50% 50%, #000 58%, #000b 78%, transparent 98%);
  mask-image:radial-gradient(closest-side circle at 50% 50%, #000 58%, #000b 78%, transparent 98%);
}

/* Stars are lit from within, so they get no terminator — a sun with a night
   side is the one body that would look wrong. */
.body-star{
  background-image:
    radial-gradient(circle at 50% 50%, var(--accent) 0%, var(--base) 46%, color-mix(in srgb, var(--base) 70%, #000) 100%),
    radial-gradient(circle at 32% 30%, color-mix(in srgb, var(--accent) 70%, transparent) 0%, transparent 34%);
  box-shadow:
    inset 0 0 14px color-mix(in srgb, var(--accent) 60%, transparent),
    0 0 22px color-mix(in srgb, var(--base) 75%, transparent);
}

/* Rock: craters. Several small offset radials, darker on one edge and lit on
   the other, which is what a crater actually looks like from above. */
.body-rocky{
  background-image:
    radial-gradient(circle at 30% 26%, color-mix(in srgb, var(--accent) 55%, transparent) 0%, transparent 40%),
    radial-gradient(circle at 62% 58%, rgba(0,0,0,.34) 0%, transparent 16%),
    radial-gradient(circle at 36% 68%, rgba(0,0,0,.28) 0%, transparent 12%),
    radial-gradient(circle at 72% 30%, rgba(0,0,0,.26) 0%, transparent 10%),
    radial-gradient(circle at 50% 44%, rgba(255,255,255,.10) 0%, transparent 9%);
}

/* Gas: banding. Horizontal because that IS how a rotating fluid planet bands,
   with one oval storm sitting in the southern bands. */
.body-gas{
  background-image:
    radial-gradient(ellipse 22% 12% at 66% 62%, color-mix(in srgb, var(--base) 40%, #7a2f1e) 0%, transparent 70%),
    linear-gradient(180deg,
      color-mix(in srgb, var(--accent) 45%, transparent) 0%,
      transparent 12%,
      color-mix(in srgb, #000 18%, transparent) 22%,
      transparent 32%,
      color-mix(in srgb, var(--accent) 35%, transparent) 44%,
      transparent 56%,
      color-mix(in srgb, #000 16%, transparent) 68%,
      transparent 78%,
      color-mix(in srgb, var(--accent) 28%, transparent) 90%);
}

/* Ice: bright, high albedo, with a cracked sheen rather than craters. */
.body-ice{
  background-image:
    radial-gradient(circle at 34% 28%, var(--accent) 0%, transparent 46%),
    linear-gradient(118deg, transparent 46%, color-mix(in srgb, var(--accent) 55%, transparent) 48%, transparent 50%),
    linear-gradient(62deg, transparent 60%, color-mix(in srgb, var(--accent) 40%, transparent) 62%, transparent 64%);
}

/* ---------- surfaces, one per world ----------

   `body-<kind>` is the treatment every object of that kind gets for free, and
   it is the right default: a new node renders sensibly the moment it is added
   to the array. But SIX bodies are kind:"rocky" and they were sharing one
   crater field -- the same three dark dots in the same three places,
   recoloured. Mercury, Earth, the Moon and Mars came out as one planet in four
   tints, and the map's whole claim is that these are real places.

   These are what `data-body` was always for. Each is the thing that actually
   distinguishes that world in a photograph of it: Mercury's battering, Earth's
   oceans and cloud, the Moon's maria, the Martian polar cap and its one
   enormous canyon, Olympus Mons as a shield with a caldera. They layer OVER
   the kind treatment rather than replacing it, so the terminator and limb
   darkening that make a circle read as a sphere stay put.

   The stars get the same. Four objects share kind:"star" and were four
   identical balls of light at different temperatures -- but a flare, a fusing
   core and a binary system do not look alike. */

/* Mercury: the most heavily cratered surface in the solar system, with the
   Caloris basin up on the limb. */
.sky-body[data-body="mercury"]{
  background-image:
    radial-gradient(circle at 68% 24%, rgba(0,0,0,.30) 0%, rgba(0,0,0,.20) 34%, transparent 52%),
    radial-gradient(circle at 30% 62%, rgba(0,0,0,.26) 0%, transparent 15%),
    radial-gradient(circle at 52% 74%, rgba(0,0,0,.24) 0%, transparent 11%),
    radial-gradient(circle at 44% 34%, rgba(0,0,0,.22) 0%, transparent 9%),
    radial-gradient(circle at 74% 60%, rgba(0,0,0,.20) 0%, transparent 8%),
    radial-gradient(circle at 22% 40%, rgba(255,255,255,.12) 0%, transparent 10%),
    radial-gradient(circle at 30% 26%, color-mix(in srgb, var(--accent) 50%, transparent) 0%, transparent 42%);
}

/* Earth: ocean underneath, land over it, and a band of cloud that lines up
   with neither. The one body here whose surface is three separate things. */
.sky-body[data-body="earth"]{
  background-image:
    radial-gradient(ellipse 26% 16% at 34% 40%, rgba(255,255,255,.55) 0%, transparent 70%),
    radial-gradient(ellipse 30% 12% at 62% 70%, rgba(255,255,255,.42) 0%, transparent 72%),
    radial-gradient(ellipse 20% 26% at 40% 58%, color-mix(in srgb, var(--accent) 85%, transparent) 0%, transparent 66%),
    radial-gradient(ellipse 16% 20% at 66% 34%, color-mix(in srgb, var(--accent) 70%, transparent) 0%, transparent 68%),
    radial-gradient(ellipse 12% 10% at 24% 74%, color-mix(in srgb, var(--accent) 60%, transparent) 0%, transparent 70%),
    radial-gradient(circle at 30% 26%, color-mix(in srgb, #dff3ff 45%, transparent) 0%, transparent 44%);
}

/* The Moon: maria, the dark basalt seas -- not craters. They are what you
   actually see from Earth, and they are far larger than any crater. */
.sky-body[data-body="the_moon"]{
  background-image:
    radial-gradient(ellipse 24% 20% at 40% 34%, rgba(0,0,0,.30) 0%, transparent 76%),
    radial-gradient(ellipse 18% 16% at 62% 44%, rgba(0,0,0,.26) 0%, transparent 74%),
    radial-gradient(ellipse 14% 12% at 50% 64%, rgba(0,0,0,.22) 0%, transparent 76%),
    radial-gradient(circle at 72% 72%, rgba(255,255,255,.16) 0%, transparent 12%),
    radial-gradient(circle at 30% 26%, color-mix(in srgb, var(--accent) 52%, transparent) 0%, transparent 44%);
}

/* Mars: a bright polar cap at the top, dark volcanic plains, and Valles
   Marineris running most of the way round as a thin dark scar. */
.sky-body[data-body="mars"]{
  background-image:
    radial-gradient(ellipse 22% 12% at 50% 8%, rgba(255,255,255,.72) 0%, transparent 80%),
    linear-gradient(96deg, transparent 44%, rgba(0,0,0,.28) 47%, rgba(0,0,0,.28) 52%, transparent 55%),
    radial-gradient(ellipse 22% 16% at 32% 60%, rgba(0,0,0,.22) 0%, transparent 76%),
    radial-gradient(ellipse 16% 14% at 70% 40%, rgba(0,0,0,.18) 0%, transparent 76%),
    radial-gradient(circle at 30% 26%, color-mix(in srgb, var(--accent) 50%, transparent) 0%, transparent 44%);
}

/* Olympus Mons: a shield volcano. A broad raised dome with a dark caldera at
   the summit and an escarpment round the base -- not a planet. */
.sky-body[data-body="olympus_mons"]{
  background-image:
    radial-gradient(circle at 48% 44%, rgba(0,0,0,.42) 0%, rgba(0,0,0,.30) 26%, transparent 34%),
    radial-gradient(circle at 46% 42%, color-mix(in srgb, var(--accent) 62%, transparent) 30%, transparent 62%),
    radial-gradient(circle at 46% 42%, transparent 60%, rgba(0,0,0,.24) 66%, transparent 78%),
    radial-gradient(circle at 30% 26%, color-mix(in srgb, var(--accent) 45%, transparent) 0%, transparent 40%);
}

/* Venus has no visible surface at all: an unbroken sulphuric cloud deck with
   faint V-shaped banding, and the brightest thing in our sky. */
.sky-body[data-body="venus"]{
  background-image:
    radial-gradient(ellipse 60% 14% at 50% 34%, color-mix(in srgb, var(--accent) 55%, transparent) 0%, transparent 76%),
    radial-gradient(ellipse 54% 12% at 46% 58%, color-mix(in srgb, var(--accent) 40%, transparent) 0%, transparent 78%),
    radial-gradient(circle at 34% 28%, color-mix(in srgb, #fff 55%, transparent) 0%, transparent 52%);
}

/* The Inner Worlds keeps the generic rocky surface on purpose: it is a GROUP
   standing for four of them, not a world of its own. */

/* Solar Flares: the loops, not the star. Arcs of plasma standing off the limb. */
.sky-body[data-body="solar_flares"]{
  background-image:
    radial-gradient(ellipse 14% 30% at 74% 30%, color-mix(in srgb, var(--accent) 90%, transparent) 0%, transparent 62%),
    radial-gradient(ellipse 10% 24% at 26% 62%, color-mix(in srgb, var(--accent) 75%, transparent) 0%, transparent 64%),
    radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--accent) 70%, transparent) 0%, var(--base) 52%, color-mix(in srgb, var(--base) 60%, #000) 100%);
  box-shadow:
    inset 0 0 16px color-mix(in srgb, var(--accent) 70%, transparent),
    0 0 30px color-mix(in srgb, var(--base) 85%, transparent);
}

/* Nuclear Fusion is the CORE, so it is brightest in the middle and falls off
   outward -- the opposite of a photosphere, which is brightest at the limb. */
.sky-body[data-body="nuclear_fusion"]{
  background-image:
    radial-gradient(circle at 50% 50%, #fff 0%, var(--accent) 18%, var(--base) 44%, color-mix(in srgb, var(--base) 45%, #000) 100%);
  box-shadow:
    inset 0 0 22px color-mix(in srgb, #fff 40%, transparent),
    0 0 34px color-mix(in srgb, var(--accent) 80%, transparent);
}

/* Alpha Centauri is a BINARY -- a bright primary with a close companion beside
   it, which is the whole reason the name is worth knowing. */
.sky-body[data-body="alpha_centauri"]{
  background-image:
    radial-gradient(circle at 72% 66%, #fff 0%, color-mix(in srgb, #ffb46a 90%, transparent) 8%, transparent 20%),
    radial-gradient(circle at 38% 40%, #fff 0%, var(--accent) 16%, var(--base) 38%, transparent 62%);
  box-shadow:0 0 30px color-mix(in srgb, var(--base) 70%, transparent);
}

/* A belt is not a body. No terminator and no disc — scattered rubble, and the
   ring of it is the shape you're meant to read. */
.body-dust{
  background-color:transparent;
  box-shadow:none;
  background-image:
    radial-gradient(circle at 20% 44%, var(--accent) 0%, transparent 7%),
    radial-gradient(circle at 74% 32%, var(--base) 0%, transparent 6%),
    radial-gradient(circle at 52% 72%, var(--accent) 0%, transparent 5%),
    radial-gradient(circle at 34% 22%, var(--base) 0%, transparent 4%),
    radial-gradient(circle at 84% 66%, var(--accent) 0%, transparent 4%),
    radial-gradient(circle at 12% 74%, var(--base) 0%, transparent 3%),
    radial-gradient(circle at 62% 12%, var(--accent) 0%, transparent 3%);
}

/* Diffuse gas — a boundary or a cavity, so it has no edge at all. The soft
   falloff is the point: you can't stand on a heliopause. */
.body-cloud{
  background-color:transparent;
  box-shadow:none;
  background-image:
    radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--accent) 40%, transparent) 0%, color-mix(in srgb, var(--base) 30%, transparent) 42%, transparent 72%),
    radial-gradient(ellipse 60% 40% at 36% 38%, color-mix(in srgb, var(--accent) 30%, transparent) 0%, transparent 60%);
  filter:blur(.4px);
}

/* A spiral: bright core, arms sweeping out of it, seen at an angle. */
.body-galaxy{
  background-color:transparent;
  box-shadow:none;
  background-image:
    radial-gradient(circle at 50% 50%, var(--accent) 0%, color-mix(in srgb, var(--accent) 50%, transparent) 12%, transparent 28%),
    conic-gradient(from 20deg at 50% 50%,
      color-mix(in srgb, var(--base) 70%, transparent) 0deg,
      transparent 60deg,
      color-mix(in srgb, var(--base) 55%, transparent) 150deg,
      transparent 220deg,
      color-mix(in srgb, var(--base) 65%, transparent) 300deg,
      transparent 360deg);
  transform:rotate(-18deg) scaleY(.62);
}

/* A black hole is a hole. The centre is genuinely black — darker than the
   canvas — and everything you can see is the accretion disc around it. */
.body-void{
  background-color:#000;
  box-shadow:0 0 20px -2px color-mix(in srgb, var(--accent) 70%, transparent);
  background-image:
    radial-gradient(circle at 50% 50%, #000 0%, #000 38%, var(--accent) 43%, color-mix(in srgb, var(--accent) 40%, transparent) 52%, transparent 62%),
    radial-gradient(ellipse 78% 16% at 50% 50%, color-mix(in srgb, var(--accent) 85%, transparent) 0%, transparent 72%);
}

/* Filaments and voids — the largest structure there is, and the only one on
   this map that isn't round. */
.body-web{
  background-color:transparent;
  box-shadow:none;
  background-image:
    linear-gradient(28deg, transparent 44%, color-mix(in srgb, var(--accent) 55%, transparent) 46%, transparent 48%),
    linear-gradient(-52deg, transparent 40%, color-mix(in srgb, var(--accent) 45%, transparent) 42%, transparent 44%),
    linear-gradient(96deg, transparent 56%, color-mix(in srgb, var(--base) 70%, transparent) 58%, transparent 60%),
    radial-gradient(circle at 32% 38%, var(--accent) 0%, transparent 6%),
    radial-gradient(circle at 68% 62%, var(--accent) 0%, transparent 5%),
    radial-gradient(circle at 56% 26%, var(--base) 0%, transparent 4%);
}

/* A body must never swallow the tap it sits on: the button underneath is the
   tap target, and .sky-body covers almost all of it. */
.sky-body{pointer-events:none}

/* ============================================================
   BESPOKE BODIES — each object drawn as ITSELF
   ============================================================

   The `body-<kind>` rules above are the generic treatment: they make any new
   node render sensibly the instant it's added to FIRMAMENT. These are the
   per-object ones, keyed on data-body, and they exist because nine kinds of
   shaded ball is still nine shaded balls. What makes a map worth looking at is
   silhouette — a thing you recognise before you've read its label.

   So: the gas giants get a ring, Alpha Centauri is three stars because it IS
   three stars, the heliopause is a bow shock rather than a sphere because it's
   the shape of the Sun's wake through the interstellar medium, and Sagittarius
   A* gets jets. Two extra paint layers per body (::before and ::after) is the
   whole budget, and none of it costs an asset.

   Anything that moves here is slow enough to read as drift rather than
   animation, and the global prefers-reduced-motion rule switches all of it
   off. */

/* ---- Sol: a corona, not an orange dot ----
   The corona is a conic gradient so it has actual structure rather than an
   even halo, and it turns once a minute — barely perceptible, but it stops the
   star reading as a static sticker. */
.sky-body[data-body="sol"]::before{
  content:"";position:absolute;inset:-42%;border-radius:50%;
  background:conic-gradient(from 0deg,
    color-mix(in srgb, var(--base) 34%, transparent) 0deg,
    transparent 26deg,
    color-mix(in srgb, var(--accent) 26%, transparent) 58deg,
    transparent 96deg,
    color-mix(in srgb, var(--base) 30%, transparent) 148deg,
    transparent 196deg,
    color-mix(in srgb, var(--accent) 22%, transparent) 244deg,
    transparent 292deg,
    color-mix(in srgb, var(--base) 28%, transparent) 330deg,
    transparent 360deg);
  -webkit-mask:radial-gradient(circle, transparent 38%, #000 52%, transparent 88%);
  mask:radial-gradient(circle, transparent 38%, #000 52%, transparent 88%);
  animation:sol-corona 68s linear infinite;
  pointer-events:none;
}
@keyframes sol-corona{to{transform:rotate(360deg)}}
/* Granulation + a couple of sunspots, so the disc has a surface. */
.sky-body[data-body="sol"]{
  background-image:
    radial-gradient(circle at 62% 58%, rgba(120,40,0,.42) 0%, transparent 9%),
    radial-gradient(circle at 38% 36%, rgba(120,40,0,.32) 0%, transparent 6%),
    radial-gradient(circle at 50% 50%, var(--accent) 0%, var(--base) 52%, color-mix(in srgb, var(--base) 62%, #7a2600) 100%);
}

/* ---- The Inner Worlds: FOUR planets, because that is what they are ----
   One sphere for four rocky planets was the laziest thing on the map. No
   shading on the container; each world is its own little lit disc. */
.sky-body[data-body="inner_worlds"]{
  background-color:transparent;box-shadow:none;
  background-image:
    radial-gradient(circle at 22% 62%, #d9d0c6 0%, #6e6259 52%, transparent 53%),
    radial-gradient(circle at 44% 34%, #f0d9a8 0%, #b08d52 55%, transparent 56%),
    radial-gradient(circle at 68% 60%, #7fc3e8 0%, #2f6f9e 55%, transparent 56%),
    radial-gradient(circle at 86% 34%, #e08a5a 0%, #8c3d22 55%, transparent 56%);
  background-size:26% 26%, 30% 30%, 32% 32%, 24% 24%;
  background-position:16% 60%, 36% 30%, 60% 58%, 82% 30%;
}

/* ---- The Belt: an annulus of rubble seen at an angle ----
   A belt is a ring with a hole in it. Drawn as one, and tilted, so it can
   never be mistaken for a planet. */
.sky-body[data-body="asteroid_belt"]{
  background-color:transparent;box-shadow:none;background-image:none;
}
.sky-body[data-body="asteroid_belt"]::before{
  content:"";position:absolute;inset:-6%;border-radius:50%;
  border:2px dotted color-mix(in srgb, var(--accent) 70%, transparent);
  transform:rotate(-16deg) scaleY(.36);
}
.sky-body[data-body="asteroid_belt"]::after{
  content:"";position:absolute;inset:0;
  background-image:
    radial-gradient(circle at 12% 50%, var(--accent) 0%, transparent 6%),
    radial-gradient(circle at 84% 46%, var(--base) 0%, transparent 5%),
    radial-gradient(circle at 46% 34%, var(--accent) 0%, transparent 4%),
    radial-gradient(circle at 62% 66%, var(--base) 0%, transparent 4%),
    radial-gradient(circle at 30% 66%, var(--accent) 0%, transparent 3%);
  transform:rotate(-16deg) scaleY(.5);
}

/* ---- The Gas Giants: bands AND a ring ----
   The ring is what makes this instantly readable at a glance, and it's the
   single most recognisable silhouette in the solar system. */
.sky-body[data-body="gas_giants"]::after{
  content:"";position:absolute;inset:-30% -46%;border-radius:50%;
  border:2px solid color-mix(in srgb, var(--accent) 62%, transparent);
  border-top-color:color-mix(in srgb, var(--accent) 22%, transparent);
  transform:rotate(-19deg) scaleY(.3);
  pointer-events:none;
}

/* ---- The Kuiper Belt: a wide, sparse, icy ring ----
   Forty AU out and mostly empty, so it's drawn far wider and fainter than the
   asteroid belt — the two must not read as the same object twice. */
.sky-body[data-body="kuiper_belt"]{
  background-color:transparent;box-shadow:none;background-image:none;
}
.sky-body[data-body="kuiper_belt"]::before{
  content:"";position:absolute;inset:-18% -30%;border-radius:50%;
  border:1px dashed color-mix(in srgb, var(--accent) 55%, transparent);
  transform:rotate(-10deg) scaleY(.42);
}
.sky-body[data-body="kuiper_belt"]::after{
  content:"";position:absolute;inset:0;
  background-image:
    radial-gradient(circle at 50% 50%, var(--accent) 0%, transparent 14%),
    radial-gradient(circle at 16% 44%, var(--accent) 0%, transparent 5%),
    radial-gradient(circle at 88% 54%, var(--accent) 0%, transparent 4%);
}

/* ---- The Heliopause: a bow shock ----
   The heliosphere is not a bubble — it's blunt into the interstellar wind and
   drawn out into a tail behind. An asymmetric border-radius gives that shape
   without a clip-path, and it's the only non-round body in the solar system. */
.sky-body[data-body="heliopause"]{
  background-color:transparent;box-shadow:none;
  border-radius:64% 36% 40% 60% / 58% 58% 42% 42%;
  background-image:
    radial-gradient(ellipse 70% 90% at 34% 50%, color-mix(in srgb, var(--accent) 42%, transparent) 0%, color-mix(in srgb, var(--base) 26%, transparent) 46%, transparent 74%);
}
.sky-body[data-body="heliopause"]::before{
  content:"";position:absolute;inset:-14% -8% -14% -22%;
  border-radius:64% 36% 40% 60% / 58% 58% 42% 42%;
  border-left:2px solid color-mix(in srgb, var(--accent) 60%, transparent);
  border-top:1px solid color-mix(in srgb, var(--accent) 30%, transparent);
  border-bottom:1px solid color-mix(in srgb, var(--accent) 30%, transparent);
}

/* ---- Alpha Centauri: THREE stars ----
   Rigil Kentaurus and Toliman are a close sunlike pair; Proxima is a small red
   dwarf well off to one side. Drawing one star here was simply wrong. */
.sky-body[data-body="alpha_centauri"]{
  background-color:transparent;box-shadow:none;
  background-image:
    radial-gradient(circle at 36% 42%, #fff8e8 0%, #ffd08a 40%, transparent 62%),
    radial-gradient(circle at 62% 56%, #fff2d8 0%, #ffbf6a 42%, transparent 60%);
  background-size:56% 56%, 40% 40%;
  background-position:22% 30%, 54% 50%;
}
.sky-body[data-body="alpha_centauri"]::after{
  content:"";position:absolute;right:2%;top:12%;width:16%;height:16%;
  border-radius:50%;background:#e8603c;
  box-shadow:0 0 7px #e8603c;
}

/* ---- The Local Bubble: a cavity, not an object ----
   An irregular hollow blown clear by supernovae. Ragged edge, stars visible
   inside it, and nothing at the centre — because there is nothing there. */
.sky-body[data-body="local_bubble"]{
  background-color:transparent;box-shadow:none;
  border-radius:58% 42% 36% 64% / 46% 62% 38% 54%;
  background-image:
    radial-gradient(circle at 50% 50%, transparent 42%, color-mix(in srgb, var(--base) 30%, transparent) 62%, transparent 82%),
    radial-gradient(circle at 34% 40%, var(--accent) 0%, transparent 4%),
    radial-gradient(circle at 68% 58%, var(--accent) 0%, transparent 3%),
    radial-gradient(circle at 52% 72%, var(--accent) 0%, transparent 3%);
  border:1px solid color-mix(in srgb, var(--base) 45%, transparent);
}

/* ---- The Orion Arm: a SEGMENT of a spiral, not a galaxy ----
   We're inside this one, so you can't see it face on. It's a swept band with
   the star-forming ridge running along it. */
.sky-body[data-body="orion_arm"]{
  background-color:transparent;box-shadow:none;
  border-radius:50%;
  background-image:
    radial-gradient(ellipse 90% 26% at 50% 50%, color-mix(in srgb, var(--accent) 55%, transparent) 0%, color-mix(in srgb, var(--base) 40%, transparent) 40%, transparent 72%),
    radial-gradient(circle at 30% 50%, var(--accent) 0%, transparent 5%),
    radial-gradient(circle at 58% 46%, var(--accent) 0%, transparent 4%),
    radial-gradient(circle at 76% 54%, var(--accent) 0%, transparent 3%);
  transform:rotate(-24deg);
}

/* ---- Sagittarius A*: a shadow, a photon ring, and jets ----
   The bright ring is lensed light passing behind it; the centre is genuinely
   black. The jets are what a supermassive black hole is actually visible by. */
.sky-body[data-body="sagittarius_a"]{
  background-color:#000;
  background-image:
    radial-gradient(circle at 50% 50%, #000 0%, #000 34%, var(--accent) 38%, color-mix(in srgb, var(--accent) 55%, transparent) 46%, transparent 58%);
  box-shadow:0 0 26px -4px color-mix(in srgb, var(--accent) 80%, transparent);
}
.sky-body[data-body="sagittarius_a"]::before{
  content:"";position:absolute;inset:-10% -34%;border-radius:50%;
  background:radial-gradient(ellipse 100% 40% at 50% 50%, color-mix(in srgb, var(--accent) 55%, transparent) 0%, transparent 68%);
  transform:rotate(-8deg) scaleY(.42);
}
.sky-body[data-body="sagittarius_a"]::after{
  content:"";position:absolute;left:50%;top:-46%;width:8%;height:192%;
  transform:translateX(-50%) rotate(14deg);
  background:linear-gradient(180deg,
    transparent 0%, color-mix(in srgb, var(--accent) 45%, transparent) 22%,
    transparent 46%, transparent 54%,
    color-mix(in srgb, var(--accent) 45%, transparent) 78%, transparent 100%);
  pointer-events:none;
}

/* ---- The Local Group: several galaxies, different sizes and angles ----
   The Milky Way, Andromeda larger than it, and the small ones. A cluster reads
   as a cluster only if its members differ. */
.sky-body[data-body="local_group"]{
  background-color:transparent;box-shadow:none;background-image:none;
}
.sky-body[data-body="local_group"]::before{
  content:"";position:absolute;left:2%;top:14%;width:52%;height:52%;
  border-radius:50%;
  background:radial-gradient(circle at 50% 50%, var(--accent) 0%, color-mix(in srgb, var(--base) 65%, transparent) 34%, transparent 70%);
  transform:rotate(-28deg) scaleY(.44);
}
.sky-body[data-body="local_group"]::after{
  content:"";position:absolute;right:0;bottom:8%;width:40%;height:40%;
  border-radius:50%;
  background:
    radial-gradient(circle at 50% 50%, var(--accent) 0%, color-mix(in srgb, var(--base) 60%, transparent) 36%, transparent 72%);
  transform:rotate(22deg) scaleY(.5);
  box-shadow:-140% -60% 0 -34% color-mix(in srgb, var(--accent) 60%, transparent),
              90% -120% 0 -36% color-mix(in srgb, var(--base) 70%, transparent);
}

/* ---- The Cosmic Web: filaments meeting at nodes ----
   Sheets and threads around voids. The bright points are where filaments
   cross, which is where the clusters actually sit. */
.sky-body[data-body="cosmic_web"]{
  background-color:transparent;box-shadow:none;
  background-image:
    linear-gradient(34deg, transparent 47%, color-mix(in srgb, var(--accent) 60%, transparent) 48.5%, transparent 50%),
    linear-gradient(-58deg, transparent 43%, color-mix(in srgb, var(--accent) 48%, transparent) 44.5%, transparent 46%),
    linear-gradient(88deg, transparent 58%, color-mix(in srgb, var(--base) 75%, transparent) 59.5%, transparent 61%),
    linear-gradient(-12deg, transparent 66%, color-mix(in srgb, var(--base) 60%, transparent) 67.5%, transparent 69%);
}
.sky-body[data-body="cosmic_web"]::after{
  content:"";position:absolute;inset:0;
  background-image:
    radial-gradient(circle at 33% 39%, var(--accent) 0%, transparent 5%),
    radial-gradient(circle at 67% 61%, var(--accent) 0%, transparent 4%),
    radial-gradient(circle at 58% 27%, var(--base) 0%, transparent 4%),
    radial-gradient(circle at 24% 70%, var(--base) 0%, transparent 3%);
}


.sky-node svg{
  width:var(--icon-size,26px);height:var(--icon-size,26px);
  position:relative;z-index:2;
}
.sky-node::before{
  content:"";position:absolute;inset:0;border-radius:50%;
  background:radial-gradient(circle at 50% 50%,
    color-mix(in srgb, currentColor 38%, transparent) 0%,
    color-mix(in srgb, currentColor 10%, transparent) 45%,
    transparent 72%);
  transition:background .4s ease;
}
.sky-node:hover{transform:translate(-50%,-50%) scale(1.09)}
.sky-node:focus-visible{outline:2px solid var(--dust);outline-offset:4px;border-radius:50%}

/* ---- branch cards ----
   A branch is a rectangular card, a spine body is a circle. Different shapes
   because they are different kinds of thing: a card is something you buy FOR a
   place, a circle IS a place. Square-ish corners and a plate behind the art, so
   a card reads as an item in a list even when its art is a little planet. */
/* A branch used to be a rounded rectangle with a plate and a 1px rim, on the
   reasoning that a different KIND of object should be a different shape. What
   it actually produced was Mercury, Venus, Earth and Mars sitting in little
   grey tiles — the same boxed look the timeline's photographs had, and for the
   same reason: a frame around a picture of a real thing turns the real thing
   into an interface element.

   They are unframed now, and what distinguishes them is what already did:
   they are smaller, they sit OFF the spine, and a drawn tether runs from each
   one back to the body it belongs to. Position and connection say "this
   belongs to that" more clearly than a border ever did.

   State still has to read, so it reads the way everything else in the
   Firmament reads — as light. This is a sky lighting up; a sky does not have
   outlines. */
.sky-node.branch::after{
  content:"";position:absolute;inset:-22%;border-radius:50%;z-index:-1;
  background:radial-gradient(closest-side circle,
    color-mix(in srgb, var(--branch-light, var(--epoch-7)) 30%, transparent) 0%,
    transparent 70%);
  pointer-events:none;
  transition:background .3s ease;
}
.sky-node.branch.affordable{--branch-light:var(--state-affordable)}
.sky-node.branch.owned{--branch-light:var(--light-bright)}
.sky-node.branch.locked{
  --branch-light:var(--line);
  /* Dimmed rather than desaturated: grayscale(1) on a lit sphere flattens the
     terminator that makes it read as a sphere at all, so a locked planet
     stopped looking like a planet. */
  opacity:.45;
}
/* Rank counter on an owned card — "3" beside the art, the card equivalent of
   the brightness the spine bodies use to show how many you hold. */
.sky-rank{
  position:absolute;right:-6px;bottom:-6px;
  min-width:17px;height:17px;padding:0 4px;border-radius:9px;
  display:grid;place-content:center;
  background:var(--void);
  box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--light-bright) 55%, transparent);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;color:var(--light-bright);
}

.sky-node.locked{color:var(--line);cursor:not-allowed}
.sky-node.locked:hover{transform:translate(-50%,-50%)}
.sky-unknown{
  width:8px;height:8px;border-radius:50%;background:var(--line);
  display:block;
}

.sky-node.available{color:var(--state-available);filter:drop-shadow(0 0 4px var(--state-available))}
.sky-node.affordable{
  color:var(--state-affordable);
  animation:sky-ready 1.8s ease-in-out infinite;
}
@keyframes sky-ready{
  0%,100%{filter:drop-shadow(0 0 5px var(--state-affordable))}
  50%{filter:drop-shadow(0 0 16px var(--state-affordable))}
}

/* --glow scales with how many you own: the sky brightens as you fill it in.

   Tinted by the OBJECT, not by the Firmament. A drop-shadow traces the alpha
   silhouette of what it is thrown by, so a hard circle throws a hard circular
   ring -- and every charted body was throwing the same cyan one. Two things
   went wrong at once: twenty different worlds wore one identical halo, and
   that halo drew a bright outline around each disc, which is precisely what
   made them read as circles stamped out of the sky rather than objects in it.

   Two shadows now: a tight one in the body's own colour that reads as its
   light, and a wide faint one that has no edge left to read. --base comes from
   the node itself (see renderSkyNode); the epoch cyan is only the fallback for
   an object with no palette of its own. */
.sky-node.owned{
  color:var(--epoch-7);
  /* Wide and weak rather than tight and strong. A tight drop-shadow on a hard
     circle is an outline of that circle, however it is coloured -- it was the
     other half of why these read as discs stamped out of the sky. Offset up
     and left as well, toward the light, so the glow sits with the lit side
     instead of ringing the object evenly. */
  filter:
    drop-shadow(-1px -1px calc(var(--glow,10px) * .9) color-mix(in srgb, var(--base, var(--epoch-7)) 45%, transparent))
    drop-shadow(0 0 calc(var(--glow,10px) * 2.2) color-mix(in srgb, var(--base, var(--epoch-7)) 22%, transparent));
}
.sky-node.keystone.owned{color:var(--light-bright)}

.sky-node.discover{animation:sky-appear 1.1s ease-out}
@keyframes sky-appear{
  0%{opacity:0;transform:translate(-50%,-50%) scale(.2)}
  60%{opacity:1;transform:translate(-50%,-50%) scale(1.15)}
  100%{transform:translate(-50%,-50%) scale(1)}
}

/* Labels sit to the right of each object, stacked — the map reads outward,
   so a label above the node would fight the direction of travel. */
/* 155px, not 130. The distance line is the widest thing in here and it is real
   prose — "300.0 million light years" is 25 characters, which overflowed a
   130px label and wrapped "years" onto a ragged line of its own, right-aligned
   by the .flip rule. Sized to the longest string the catalogue can produce
   rather than shortening the wording to fit the box. tools/canvas-test.js
   measures collisions at this width, so the two cannot drift apart. */
.sky-label{
  position:absolute;transform:translateY(-50%);
  display:flex;flex-direction:column;gap:1px;
  pointer-events:none;width:155px;
}
.sky-label-name{font-size:12px;font-weight:500;color:var(--dust);line-height:1.2}
.sky-label-dist{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;color:var(--epoch-7);opacity:.85;
}
.sky-label-cost{font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;color:var(--muted)}
/* What would open an unreached object, in place of a price it doesn't have. */
.sky-label-req{font-size:9.5px;line-height:1.3;color:var(--muted);opacity:.85}
/* An unreached object's label reads quieter than a charted one, so the charted
   sky still carries the eye while the dark half stays legible. */
.sky-label.dim .sky-label-name{color:var(--muted)}
.sky-label.dim{opacity:.72}

/* .sky-exit is gone: the Back button moved into the View rail, which occupies
   exactly the corner this rule was pinning it to. */
.firmament-back{
  background:transparent;border:1px solid var(--line);color:var(--muted);
  font:inherit;font-size:12px;padding:9px 18px;border-radius:20px;cursor:pointer;
}
.firmament-back:hover{border-color:var(--epoch-7);color:var(--epoch-7)}
/* The zone jump used to be a native <select> and had a whole block here:
   appearance:none, 30px of padding, and an arrow drawn from two linear
   gradients. It is a <div> wrapper now with a real button inside it, and every
   one of those declarations was still landing on the wrapper — a phantom
   triangle painted next to the actual chevron, and padding on an element that
   no longer has text. Deleted rather than adapted; see "the zone menu" below
   for what replaced it. */

.sky-icon{border-color:var(--epoch-7);color:var(--epoch-7)}
.sky-epoch{color:var(--epoch-7)}
.sky-buy{--node-color:var(--epoch-7)}

@media (max-height:620px){
  .firmament-title{font-size:clamp(18px,5vw,24px)}
  .firmament-view{padding-top:calc(env(safe-area-inset-top) + 10px)}
}

/* On a narrow screen the stack is the same, only tighter — there is no longer
   a centred title and a right-aligned block competing for one 390px row, which
   is what this media query used to exist to untangle. */
@media (max-width:560px){
  .firmament-reach{font-size:clamp(18px,6vw,24px)}
  .sky-light-line{font-size:clamp(24px,9vw,34px);margin-top:6px}
  .sky-readout-top{max-width:calc(100vw - 28px)}
}

/* ---------- welcome-back panel ---------- */
.offline-figure{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:clamp(26px,8vw,40px);
  color:var(--state-affordable);letter-spacing:-.02em;margin-bottom:4px;
}
.offline-detail{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:12px;color:var(--muted);
  margin-bottom:18px;
}
.offline-cap{
  display:none;   /* only once JS clears [hidden] — see the note in CLAUDE.md */
  font-size:12px;line-height:1.5;color:var(--muted);
  border-left:2px solid var(--line);padding-left:10px;margin-bottom:18px;
}
.offline-cap:not([hidden]){display:block}
.offline-dismiss{--node-color:var(--accent)}

.daily-block{
  display:none;   /* only once JS clears [hidden] */
  border:1px solid var(--accent);border-radius:12px;
  padding:14px 16px;margin-bottom:18px;
  background:var(--accent-soft);
}
.daily-block:not([hidden]){display:block}
.daily-streak{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;letter-spacing:.18em;
  text-transform:uppercase;color:var(--accent);margin-bottom:6px;
}
.daily-reward{font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:20px;color:var(--dust)}
.daily-note{font-size:12px;line-height:1.5;color:var(--muted);margin-top:6px}

/* ---------- epoch transition — the payoff for the whole loop ---------- */
.transition-overlay{
  position:fixed;inset:0;z-index:30;display:none;align-items:center;justify-content:center;
  padding:clamp(20px,6vw,32px);text-align:center;
  background:radial-gradient(700px 500px at 50% 40%, var(--accent-soft), var(--void) 75%);
  opacity:0;pointer-events:none;transition:opacity .5s ease;
}
/* only take over the screen (and start intercepting clicks) once JS removes `hidden` */
.transition-overlay:not([hidden]){display:flex;cursor:pointer}
.transition-overlay.show{opacity:1;pointer-events:auto}

.transition-label{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:12px;letter-spacing:.3em;text-transform:uppercase;
  color:var(--muted);margin-bottom:14px;
  opacity:0;transform:translateY(6px);
  animation:transition-rise .6s ease-out .15s forwards;
}
.transition-name{
  font-family:"Source Serif 4",serif;font-weight:600;color:var(--accent);
  font-size:clamp(30px,10vw,56px);line-height:1.05;margin-bottom:20px;
  opacity:0;transform:translateY(10px) scale(.94);
  animation:transition-rise-name .8s cubic-bezier(.2,.8,.3,1) .35s forwards;
}
.transition-flavour{
  max-width:420px;font-size:14px;line-height:1.6;color:var(--dust);
  opacity:0;transform:translateY(6px);
  animation:transition-rise .6s ease-out .75s forwards;
}
@keyframes transition-rise{to{opacity:1;transform:translateY(0)}}
@keyframes transition-rise-name{to{opacity:1;transform:translateY(0) scale(1)}}

@media (max-height:560px){
  .core{width:clamp(84px,22vw,120px);height:clamp(84px,22vw,120px)}
  .bottom-hud{gap:8px;padding-bottom:calc(env(safe-area-inset-bottom) + 12px)}
  .hud-top{padding:10px 12px 10px;gap:4px}
  /* The left rail can reach four buttons once the install pill appears, and a
     landscape phone doesn't have 290px of vertical rail to give. The captions
     stay — they're the reason the rail is legible at all. */
  .hud-rail{gap:14px;top:12px}
  /* The banner clears the core on a tall screen; on a short one it has to come
     down to the toast's own height or it lands in the middle of the tree. */
  .update-banner{bottom:calc(env(safe-area-inset-bottom) + 12px)}
  .epoch-name{font-size:clamp(18px,4.5vw,24px);margin-top:2px}
  .epoch-next{margin-top:2px}
  .count{font-size:clamp(20px,6vw,30px)}
  .rate{margin-top:0}
  .cosmic-clock{margin-top:2px;font-size:10px}
  /* Narrower, because the map is narrower — but NOT smaller. A phone is the
     screen where an unreadable label is least recoverable. */
  .tree-node-name{font-size:12px;max-width:104px;padding:2px 7px}
  /* the readability pass gave these room to breathe; a short/landscape
     screen doesn't have the room to give, so they go back to compact */
  .first-buy-hint{font-size:12px;line-height:1.5;padding:10px 28px 10px 14px}
  .event-prompt{padding:14px 20px;gap:5px}
  .event-prompt-title{font-size:17px}
  .event-prompt-sub{font-size:12px}
}

@media (prefers-reduced-motion:reduce){
  *{animation:none!important;transition:none!important}
  .tree-node.available, .tree-node.affordable{animation:none}
  /* With its animation off the burst ring would just sit at full opacity for
     the 600ms before burstNode() removes it — a flash, not a flourish. */
  .node-burst{display:none}
}

/* ---------- account, profile and friends ---------- */
.account-form{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}
.account-field{display:none;flex-direction:column;gap:6px}
/* #nameField is toggled with the `hidden` attribute (it only belongs on the
   Create account tab), and a bare `display` in the base rule would beat the
   browser's own [hidden]{display:none} regardless of specificity. See the
   standing note in CLAUDE.md — this has now shipped as a real bug three times. */
.account-field:not([hidden]){display:flex}
.account-field span{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.16em;
  text-transform:uppercase;color:var(--muted);
}
.account-field input, .account-inline input{
  background:var(--void);border:1px solid var(--line);border-radius:8px;
  color:var(--dust);font:inherit;font-size:14px;padding:11px 13px;
}
.account-field input:focus, .account-inline input:focus{outline:none;border-color:var(--accent)}
.account-inline{flex-direction:row;gap:10px;flex-wrap:wrap;align-items:center}
.account-inline input{
  flex:1 1 160px;min-width:0;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;letter-spacing:.18em;text-transform:uppercase;
}
.account-submit{
  background:var(--accent);border:0;border-radius:8px;color:var(--void);
  font:inherit;font-weight:500;font-size:14px;padding:12px 20px;cursor:pointer;
}
.account-submit:disabled{opacity:.5;cursor:not-allowed}
.account-toggle{
  background:none;border:0;color:var(--muted);font:inherit;font-size:12px;
  cursor:pointer;padding:4px 0;text-decoration:underline;
}
.account-toggle:hover{color:var(--dust)}
.account-error{
  display:none;   /* only once JS clears [hidden] — see the note in CLAUDE.md */
  font-size:12px;line-height:1.5;color:var(--epoch-4);
  border-left:2px solid var(--epoch-4);padding-left:10px;
}
.account-error:not([hidden]){display:block}
.friend-code{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;letter-spacing:.2em;color:var(--accent);
}

.friend-row{display:flex;gap:12px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}
.friend-row:last-child{border-bottom:0}
.friend-body{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
.friend-name{font-weight:500;font-size:14px;overflow-wrap:anywhere}
.friend-meta{font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;color:var(--muted)}
.friend-pip{
  flex:0 0 auto;width:30px;height:30px;border-radius:50%;
  display:grid;place-content:center;
  background:var(--void);border:2px solid var(--epoch-color,var(--accent));
  color:var(--epoch-color,var(--accent));
  font-family:"Source Serif 4",serif;font-weight:600;font-size:14px;
}
/* The pip borrows the epoch palette, so how far along a friend is reads as
   colour in the list exactly as it does on the tree. */
.friend-pip[data-epoch="1"]{--epoch-color:var(--epoch-1)}
.friend-pip[data-epoch="2"]{--epoch-color:var(--epoch-2)}
.friend-pip[data-epoch="3"]{--epoch-color:var(--epoch-3)}
.friend-pip[data-epoch="4"]{--epoch-color:var(--epoch-4)}
.friend-pip[data-epoch="5"]{--epoch-color:var(--epoch-5)}
.friend-pip[data-epoch="6"]{--epoch-color:var(--epoch-6)}
.friend-pip[data-epoch="7"]{--epoch-color:var(--epoch-7)}
.friend-pip[data-epoch="8"]{--epoch-color:var(--epoch-8)}
.friend-pip[data-epoch="9"]{--epoch-color:var(--epoch-9)}
.friend-pip[data-epoch="10"]{--epoch-color:var(--epoch-10)}

.request-actions{display:flex;gap:6px;flex-shrink:0}
.request-actions button{
  background:none;border:1px solid var(--line);color:var(--muted);
  font:inherit;font-size:11px;padding:6px 10px;border-radius:6px;cursor:pointer;
}
.request-actions button:hover{border-color:var(--accent);color:var(--accent)}

/* Friends on the tree. These live on #treeCanvas with the nodes, so they pan
   and zoom with it for free — no second transform to keep in step. */
.friend-marker{
  position:absolute;transform:translate(-50%,-50%);
  display:flex;flex-direction:column;align-items:center;gap:3px;
  pointer-events:none;   /* never in the way of buying the node underneath */
  z-index:4;
}
.friend-marker .friend-pip{width:26px;height:26px;font-size:12px;box-shadow:0 0 12px -4px var(--epoch-color,var(--accent))}
.friend-marker-name{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:9px;letter-spacing:.08em;
  color:var(--dust);background:var(--void-2);border:1px solid var(--line);
  border-radius:10px;padding:2px 7px;white-space:nowrap;max-width:110px;
  overflow:hidden;text-overflow:ellipsis;
}

.conflict-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:18px;margin-top:18px}
.conflict-card{border:1px solid var(--line);border-radius:12px;padding:16px}
.conflict-card .account-submit{width:100%;margin-top:16px}
.conflict-card .account-submit[disabled]{opacity:.5;pointer-events:none}

/* The rows that differ ARE the decision — without this the two cards read as
   the same four numbers twice and the player has to diff them by eye. */
.stat-cell-ahead .stat-value{color:var(--accent)}
.stat-cell-ahead .stat-label::after{content:" 91";opacity:.7}

.conflict-note{margin-top:10px;opacity:.75}
#conflictError{margin-top:16px;text-align:center}

/* ---------- settings rows (inside the profile panel) ---------- */
.setting-row{
  display:flex;gap:16px;align-items:center;justify-content:space-between;
  padding:14px 0;border-bottom:1px solid var(--line);flex-wrap:wrap;
}
.setting-row:last-child{border-bottom:0}
.setting-body{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1 1 200px}
.setting-name{font-size:14px;font-weight:500;color:var(--dust)}
.setting-desc{font-size:12px;line-height:1.5;color:var(--muted)}
.setting-row > button{
  flex:0 0 auto;
  background:none;border:1px solid var(--line);color:var(--dust);
  font:inherit;font-size:12px;padding:9px 14px;border-radius:6px;cursor:pointer;
  transition:border-color .15s ease, color .15s ease;
}
.setting-row > button:hover{border-color:var(--accent);color:var(--accent)}
/* The save buttons are a group, so they wrap as one under their description
   rather than one of them stranding itself on a line of its own. */
.setting-stack{align-items:flex-start}
.setting-stack .save-actions{flex:1 1 100%}
.setting-danger .setting-name{color:var(--epoch-4)}
.setting-danger > button{border-color:var(--epoch-4);color:var(--epoch-4)}
.setting-danger > button:hover{background:var(--epoch-4);color:var(--void);border-color:var(--epoch-4)}

.profile-line{font-family:"Source Serif 4",serif;font-size:20px;font-weight:600;margin-bottom:6px}
.drawer-footer-note{font-size:11px;color:var(--muted);line-height:1.4}

/* The legal links. Deliberately the quietest thing in the drawer — they must be
   findable, and they must not compete with anything a player came here to do. */
.drawer-legal{display:flex;gap:10px;flex-wrap:wrap}
.drawer-legal a{color:var(--muted);text-decoration:none;border-bottom:1px solid transparent}
.drawer-legal a:hover{color:var(--accent);border-bottom-color:var(--accent)}

/* ---------- the shop ---------- */
/* ============================================================
   THE SHOP — a cabinet of specimens, not a list of products

   The governing idea: a cosmetics shop where you cannot see the cosmetic is
   selling text. Every card therefore RENDERS THE THING, using the very rules
   that will style it once worn — the cosmetics block below adds `.spec-orb`,
   `.spec-palette` and `.spec-shapes` to its own selectors rather than
   restating a single gradient here. One source of truth: a new skin is still
   one block in this file, and it turns up in the shop for free.

   Scale carries price. `--tier` (1..3, set inline from the gem cost) drives
   the specimen size and, past 560px, lets the dearest things take two
   columns. Below that everything is one column, because a two-column span in
   a two-column grid is just a full-width card with worse spacing.
   ============================================================ */

.gem-balance{
  display:none;
  align-items:center;gap:10px;margin-bottom:clamp(16px,3vw,22px);
  padding:clamp(9px,1.6vw,12px) clamp(14px,2.4vw,18px);
  border-radius:14px;
  background:linear-gradient(135deg, var(--accent-soft), transparent 70%);
  border:1px solid var(--accent);color:var(--accent);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:clamp(13px,2.4vw,15px);
}
.gem-balance:not([hidden]){display:inline-flex}
.gem-balance::before{
  content:"";width:13px;height:13px;flex:none;background:currentColor;
  clip-path:polygon(50% 0%, 100% 36%, 50% 100%, 0% 36%);
}

/* The shop gets its own width. The 560px reading column is right for the
   guide, which is prose; it is wrong for a cabinet, where the whole point is
   that objects sit next to each other at different scales. */
.shop-panel{max-width:min(1040px, 96vw)}

/* ---------- section headers: a plate number, set enormous ----------
   The numeral is the loudest thing on the screen and carries no information
   — which is exactly why it can be that loud. It is aria-hidden and bleeds
   off the left edge, so the eye reads it as a mark on the cabinet rather
   than as text it has to parse. */
.spec-section{position:relative;margin-bottom:clamp(30px,6vw,58px)}
.spec-section-head{
  display:flex;align-items:flex-end;gap:clamp(10px,2vw,18px);
  margin-bottom:clamp(12px,2.4vw,20px);
  border-bottom:2px solid var(--dust);padding-bottom:9px;
}
.spec-section-index{
  font-family:"Source Serif 4",serif;font-weight:800;font-variant-numeric:tabular-nums;
  font-size:clamp(34px,9vw,68px);line-height:.76;letter-spacing:-.05em;
  color:transparent;-webkit-text-stroke:1.5px var(--accent);
  flex:none;margin-left:clamp(-6px,-1.4vw,-14px);
  opacity:.85;
}
.spec-section-title{
  font-family:"Source Serif 4",serif;font-weight:800;
  font-size:clamp(16px,4.4vw,30px);letter-spacing:-.03em;line-height:.95;
  color:var(--dust);text-transform:uppercase;flex:1;
}
.spec-section-count{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:clamp(10px,2vw,12px);color:var(--muted);flex:none;padding-bottom:2px;
}

/* ---------- the grid: uneven in WIDTH, level in rows ----------
   The dear pieces still claim two columns — that is the hierarchy, and a tidy
   matrix is what made this look ordinary. What they no longer claim is two
   ROWS. A double-height card leaves a hole beside it that `dense` backfills
   with whatever comes next, so a neighbour ends up stranded in a tall narrow
   column with a third of it empty. That is not rhythm, it is a gap.

   Gone with it: `.spec[data-tier="2"]:nth-of-type(4n+1)`, which widened
   whichever tier-2 item happened to land on a multiple of four. It made the
   layout depend on catalogue order rather than on what anything costs, so the
   same shop redrawn with one new item rearranged itself for no reason a player
   could see. */
.spec-grid{
  display:grid;gap:clamp(10px,1.8vw,16px);
  grid-template-columns:repeat(auto-fill,minmax(min(100%,152px),1fr));
  grid-auto-flow:row dense;
}
@media (min-width:600px){
  .spec[data-tier="3"]{grid-column:span 2}
}

/* ---------- the card ---------- */
/* Lit from above rather than boxed in. The old card was a 1px rectangle
   around every item regardless of what it cost; light and shadow can say
   "this one matters" where a uniform border cannot. */
.spec{
  position:relative;display:flex;flex-direction:column;gap:8px;
  padding:clamp(11px,2.2vw,16px);
  border:1px solid transparent;border-radius:3px 3px 14px 14px;
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, transparent), transparent 32%),
    linear-gradient(160deg, var(--void-2), var(--void) 78%);
  box-shadow:inset 0 1px 0 color-mix(in srgb, var(--dust) 14%, transparent),
             0 12px 24px -20px #000;
  text-align:left;color:inherit;font:inherit;cursor:pointer;
  transition:transform .2s cubic-bezier(.2,.8,.3,1), box-shadow .2s ease, border-color .2s ease;
}
.spec:hover:not(:disabled){
  border-color:var(--accent);transform:translateY(-5px);
  box-shadow:inset 0 1px 0 color-mix(in srgb, var(--dust) 22%, transparent),
             0 22px 40px -26px var(--accent);
}
/* The dear things get a brighter rim than the cheap ones, all the time. */
.spec[data-tier="3"]{
  border-color:color-mix(in srgb, var(--accent) 30%, transparent);
  border-radius:3px 3px 18px 18px;
}
.spec:disabled{cursor:default}
.spec.is-owned{border-color:var(--state-owned)}

/* Price made physical. Both values feed clamp()s, so the tier sets the
   ceiling and the viewport still gets the final say on small screens. */
.spec[data-tier="1"]{--well:64px;--orb:32px}
.spec[data-tier="2"]{--well:86px;--orb:48px}
.spec[data-tier="3"]{--well:116px;--orb:68px}

/* The vitrine. overflow:hidden is load-bearing — the core skins carry an
   80px glow sized for a 96px core, and clipping it to the well turns that
   spill into the specimen's own light instead of a bloom over the card. */
.spec-well{
  position:relative;display:grid;place-items:center;
  height:clamp(58px,13vw,var(--well,78px));
  border-radius:9px;overflow:hidden;
  background:radial-gradient(circle at 50% 120%, var(--void-2), var(--void) 70%);
  border:1px solid var(--line);
}

/* ---------- specimen: a core skin ---------- */
.spec-orb{
  position:relative;border-radius:50%;
  width:clamp(30px,8vw,var(--orb,46px));aspect-ratio:1;
  border:1px solid var(--line);
  background:radial-gradient(circle at 50% 45%, var(--accent), var(--void-2) 90%);
}
.spec-orb-label{
  position:absolute;inset:0;display:grid;place-items:center;
  font-family:"Source Serif 4",serif;font-size:9px;color:var(--void);
}

/* ---------- specimen: a palette (the seven epochs, struck in ink) ---------- */
.spec-palette{display:flex;width:100%;height:100%}
.spec-palette i{flex:1}
.spec-palette i:nth-child(1){background:var(--epoch-1)}
.spec-palette i:nth-child(2){background:var(--epoch-2)}
.spec-palette i:nth-child(3){background:var(--epoch-3)}
.spec-palette i:nth-child(4){background:var(--epoch-4)}
.spec-palette i:nth-child(5){background:var(--epoch-5)}
.spec-palette i:nth-child(6){background:var(--epoch-6)}
.spec-palette i:nth-child(7){background:var(--epoch-7)}
.spec-palette i:nth-child(8){background:var(--epoch-8)}
.spec-palette i:nth-child(9){background:var(--epoch-9)}
.spec-palette i:nth-child(10){background:var(--epoch-10)}

/* ---------- specimen: a shape set (the real clip-paths) ---------- */
.spec-shapes{display:flex;gap:5px;align-items:center;flex-wrap:wrap;justify-content:center;padding:6px}
.spec-shapes i{
  width:clamp(9px,2.4vw,14px);aspect-ratio:1;flex:none;
  clip-path:var(--shape);
}
.spec-shapes i[data-epoch="1"]{background:var(--epoch-1)}
.spec-shapes i[data-epoch="2"]{background:var(--epoch-2)}
.spec-shapes i[data-epoch="3"]{background:var(--epoch-3)}
.spec-shapes i[data-epoch="4"]{background:var(--epoch-4)}
.spec-shapes i[data-epoch="5"]{background:var(--epoch-5)}
.spec-shapes i[data-epoch="6"]{background:var(--epoch-6)}
.spec-shapes i[data-epoch="7"]{background:var(--epoch-7)}
.spec-shapes i[data-epoch="8"]{background:var(--epoch-8)}
.spec-shapes i[data-epoch="9"]{background:var(--epoch-9)}
.spec-shapes i[data-epoch="10"]{background:var(--epoch-10)}

/* ---------- specimen: a title, shown as the nameplate it becomes ---------- */
.spec-plate{
  padding:5px 13px;border:1px solid var(--accent);border-radius:100px;
  background:var(--accent-soft);color:var(--accent);
  font-family:"Source Serif 4",serif;font-size:clamp(10px,2.4vw,13px);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:92%;
}

/* ---------- specimen: a booster, read as the multiplier it is ---------- */
.spec-factor{
  font-family:"Source Serif 4",serif;font-weight:800;line-height:.9;
  font-size:clamp(19px,5.5vw,30px);color:var(--state-affordable);
}
.spec-factor small{
  display:block;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-weight:400;
  font-size:10px;color:var(--muted);margin-top:5px;letter-spacing:.06em;
}

/* ---------- specimen: the four effects, sketched ----------

   PAUSED BY DEFAULT. These preview animated cosmetics, so they have to be able
   to move — but a grid of forty cards all looping at once made the shop read
   as a slot machine rather than a catalogue. They run when you hover or focus
   the card they are in, which is also the moment you are actually asking what
   the thing does.

   `animation-play-state` rather than removing the animation: the frame stays
   composed, so a paused specimen shows the effect mid-flight instead of an
   empty box. */
.spec-fx{position:relative;width:100%;height:100%;overflow:hidden}
.spec-fx *,
.spec-orb::after{animation-play-state:paused}
.spec:hover .spec-fx *,
.spec:focus-visible .spec-fx *,
.spec:hover .spec-orb::after,
.spec:focus-visible .spec-orb::after{animation-play-state:running}

/* Someone who has asked the operating system for less motion gets none of it,
   hover or not. */
@media (prefers-reduced-motion:reduce){
  .spec-fx *,
  .spec-orb::after,
  .spec:hover .spec-fx *,
  .spec:hover .spec-orb::after{animation:none}
}
.spec-fx[data-fx="fx_trail_dust"] i{
  position:absolute;bottom:12%;width:5px;height:5px;border-radius:50%;
  background:radial-gradient(circle, var(--accent), transparent 70%);
  animation:spec-rise 2.1s ease-out infinite;
}
.spec-fx[data-fx="fx_trail_dust"] i:nth-child(1){left:32%;animation-delay:0s}
.spec-fx[data-fx="fx_trail_dust"] i:nth-child(2){left:50%;animation-delay:.5s}
.spec-fx[data-fx="fx_trail_dust"] i:nth-child(3){left:66%;animation-delay:1s}
@keyframes spec-rise{from{transform:translateY(0);opacity:0}30%{opacity:1}to{transform:translateY(-38px);opacity:0}}

.spec-fx[data-fx="fx_ripple_ring"] i{
  position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;
  border:2px solid var(--accent);border-radius:50%;
  animation:spec-ripple 1.7s ease-out infinite;
}
.spec-fx[data-fx="fx_ripple_ring"] i:nth-child(2){animation-delay:.85s}
@keyframes spec-ripple{from{transform:scale(.3);opacity:1}to{transform:scale(3.4);opacity:0}}

.spec-fx[data-fx="fx_connector_arc"] i{
  position:absolute;left:8%;right:8%;top:50%;height:2px;
  background:repeating-linear-gradient(90deg, var(--accent) 0 3px, transparent 3px 10px);
  animation:spec-flow .6s linear infinite;
}
@keyframes spec-flow{to{background-position:10px 0}}

.spec-fx[data-fx="fx_sky_dense"]{
  background-image:radial-gradient(1.5px 1.5px at 20% 30%, var(--dust), transparent),
    radial-gradient(1.5px 1.5px at 70% 25%, var(--dust), transparent),
    radial-gradient(1.5px 1.5px at 45% 60%, var(--dust), transparent),
    radial-gradient(1.5px 1.5px at 85% 70%, var(--dust), transparent),
    radial-gradient(1.5px 1.5px at 12% 75%, var(--dust), transparent),
    radial-gradient(1.5px 1.5px at 60% 85%, var(--dust), transparent),
    radial-gradient(1.5px 1.5px at 32% 12%, var(--dust), transparent);
}

/* ---------- card text ---------- */
.spec-name{
  font-family:"Source Serif 4",serif;font-size:clamp(12.5px,2.7vw,15px);
  line-height:1.25;color:var(--dust);
}
.spec-desc{
  font-size:clamp(10.5px,2.2vw,11.5px);line-height:1.5;color:var(--muted);flex:1;
}
.spec-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:2px}
.spec-cost{
  display:inline-flex;align-items:center;gap:5px;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:clamp(11.5px,2.4vw,13px);color:var(--accent);
}
.spec-cost::before{
  content:"";width:9px;height:9px;flex:none;background:currentColor;
  clip-path:polygon(50% 0%, 100% 36%, 50% 100%, 0% 36%);
}
.spec-cost.cant{color:var(--muted)}
.spec-tag{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.08em;
  text-transform:uppercase;padding:3px 8px;border-radius:100px;border:1px solid currentColor;
}
.spec-tag.owned{color:var(--state-owned)}
.spec-tag.running{color:var(--state-affordable)}
/* An owned cosmetic is an invitation, not a receipt: the tag is the button's
   own label, and it says what pressing it will do. */
.spec-tag.wear{color:var(--accent)}
.spec-tag.worn{
  color:var(--void);background:var(--state-affordable);border-color:var(--state-affordable);
}
/* Worn reads at a glance from across the grid — you should be able to see what
   you have on without reading a single tag. */
.spec.is-worn{
  border-color:var(--state-affordable);
  box-shadow:inset 0 1px 0 color-mix(in srgb, var(--dust) 22%, transparent),
             0 0 0 1px color-mix(in srgb, var(--state-affordable) 45%, transparent),
             0 18px 34px -24px var(--state-affordable);
}
.spec.is-worn .spec-well{border-color:color-mix(in srgb, var(--state-affordable) 45%, transparent)}
.spec.is-worn:hover{border-color:var(--state-affordable)}

/* ---------- gem packs: the only real-money row, so it leads ----------
   Sized by what they cost, not evenly: a price ladder that all looks the same
   is a ladder with no top.

   Every measurement here arrives as an inline custom property from
   gemPacks(). It used to be nth-child(2) and nth-child(3), written when the
   catalogue had exactly three packs — the fourth and fifth got the default
   small vitrine, no rung on the ladder, and (via the matching bug in gemSvg)
   no gem in the case at all. Position-based styling is a promise about how
   many of something there will be, and this row could not keep it. */
.spec-grid.packs{
  grid-template-columns:repeat(auto-fit,minmax(min(100%,150px),1fr));
  align-items:end;
}
.spec-pack{
  align-items:center;text-align:center;gap:9px;
  border-color:color-mix(in srgb, var(--accent) 45%, transparent);
}
/* The best gems-per-rand in the row, whichever pack that turns out to be. */
@media (min-width:600px){
  .spec-pack[data-featured]{
    transform:translateY(-14px);
    border-color:var(--accent);
    box-shadow:inset 0 1px 0 color-mix(in srgb, var(--dust) 22%, transparent),
               0 26px 46px -28px var(--accent);
  }
  .spec-pack[data-featured]:hover:not(:disabled){transform:translateY(-19px)}
}
.spec-pack .spec-well{
  background:
    radial-gradient(circle at 50% 118%, var(--accent-soft), transparent 62%),
    radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--dust) 8%, transparent), transparent 55%),
    var(--void);
}
/* --gem-scale is the pack's own rung, set inline by gemPacks(). */
.spec-gem{
  width:calc(clamp(26px,7vw,42px) * var(--gem-scale, 1));
  height:auto;display:block;overflow:visible;
  filter:drop-shadow(0 3px 10px color-mix(in srgb, var(--accent) 45%, transparent));
}
.spec-gem .facet-l{fill:var(--accent);opacity:.55}
.spec-gem .facet-r{fill:var(--accent);opacity:.85}
.spec-gem .facet-t{fill:var(--dust);opacity:.9}
.spec-pack-gems{
  font-family:"Source Serif 4",serif;font-weight:800;
  font-size:clamp(16px,4vw,22px);color:var(--dust);line-height:1;
}
.spec-pack-name{font-family:"Source Serif 4",serif;font-size:clamp(11px,2.4vw,13px);color:var(--muted)}
.spec-pack-price{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:clamp(12px,2.6vw,14px);color:var(--accent);
}
.spec-pack-bonus{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;color:var(--state-affordable);
  border:1px solid currentColor;border-radius:100px;padding:2px 8px;
}

/* The deep-collapse note in the confirm dialog. display:none in the base rule
   because JS toggles `hidden` on it — the house pattern. */
.collapse-deep{
  display:none;
  margin:14px auto 0;max-width:44ch;
  font-size:12px;line-height:1.55;color:var(--state-affordable);
}
.collapse-deep:not([hidden]){display:block}

/* ---------- the Fine Structure ----------

   Concentric rings, read from the outside in. This was a scrolling list of
   cards first and it read as a settings page rather than a place — the two
   other areas are spaces with art and atmosphere, and this was five boxes in
   a void.

   The rings are stacked plain circles, largest first, each smaller one drawn
   over the middle of the last. That is what makes the annuli: no mask, no
   clip-path, no SVG, and each band's photograph simply fills its own circle
   with the next ring covering the part that should not show.

   It is NOT the Firmament in a different shape, and the differences are
   enforced here as much as in the JS: the whole diagram fits the viewport at
   every size, so there is no camera and no viewport CSS anywhere in this
   block; it runs inward toward a point rather than outward along a rail; and
   the ring is the band, rather than being a place where objects sit. */
/* z-index 22, the same as .firmament-view, and NOT the 40 this shipped with.

   At 40 this sat above the HUD rail and covered every control in the game:
   no Shop, no Season, no You, no Sound, no Help — a whole place with one
   button in it. The Firmament solved this long ago and the arrangement is
   deliberate: the view goes under the rail, the rail is lifted above it, and
   the two HUD pieces that mean nothing here (the dust counter and the core)
   are hidden rather than left showing a timeline you are not looking at.

   THE VIEW NO LONGER SCROLLS. It was `overflow-y:auto` when the nest and the
   panel were a two-column grid that could outgrow the window. The nest is now
   sized off the SHORT edge and the panel is an overlay, so the place fits by
   construction at every size — and a screen whose whole subject is one object
   should not be something you can accidentally drag off the top of. */
.structure-view{
  position:fixed;inset:0;z-index:22;
  overflow:hidden;overscroll-behavior:contain;
  opacity:0;transition:opacity .26s ease;

  /* Not flat --void any more. Two very soft warm lobes above and below the
     centre and a cold wash behind them: what is left of a disk seen almost
     edge on. It is CSS rather than canvas on purpose — it never changes, so
     paying for it once at composite time is right, and it means the lensed
     field in js/horizon.js has something to be lensed IN FRONT OF rather than
     hanging in flat black. */
  background:
    radial-gradient(58% 30% at 50% 27%, #f0a35e12, transparent 70%),
    radial-gradient(58% 30% at 50% 73%, #a97bf012, transparent 70%),
    radial-gradient(70% 70% at 50% 50%, #211830 0%, var(--void) 62%),
    var(--void);
}
.structure-view.show{opacity:1}

/* How big the nest is, in one place, because it is the answer to a different
   sum at each breakpoint and in each panel state. Read by .structure-shells. */
.structure-view{--nest:min(92vw, 78vh, 860px)}

/* Same three rules the Firmament uses, for the same reasons. */
body.structure-open .hud-rail{z-index:23}
body.structure-open .bottom-hud,
body.structure-open .hud-top{display:none}
body.structure-open .map-actions{display:none}

/* Back lives at the top of the View rail with the other controls that change
   what you are looking at, not floating in the corner the rail occupies. */
#structureClose{display:none}
body.structure-open #structureClose{display:grid}

/* The sky. Under everything, above nothing, and never in the way — the rings
   are opaque, so the entire lensing effect happens in the space AROUND the
   nest, which is precisely the space this screen used to leave empty. */
.structure-sky{
  position:absolute;inset:0;z-index:0;
  width:100%;height:100%;
  pointer-events:none;
}

/* The header floats over the sky rather than pushing the diagram down. It used
   to own a `margin-bottom` of up to 40px, which came straight off the height
   the nest could use — on a laptop that was the difference between a diagram
   that filled the screen and one that sat in the middle of it. */
.structure-head{
  position:absolute;z-index:2;
  top:clamp(52px,8vh,78px);left:0;right:0;
  text-align:center;pointer-events:none;
}
.structure-eyebrow{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.42em;text-transform:uppercase;color:var(--muted);
}
/* There is no .structure-charge rule any more, and no element for it. The
   header printed the Charge total at up to 42px directly above a diagram that
   now fills the screen; the number lives at the centre of the nest, which is
   the point everything here converges on. */
.structure-depth{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:clamp(11px,2.5vw,13px);color:var(--muted);
  margin-top:5px;
}

/* One column, centred in the whole viewport. The stage was a two-column grid
   with the detail panel as its right half; the panel is an overlay now, so
   the diagram is the screen. */
.structure-stage{
  position:relative;z-index:1;
  height:100%;
  /* Flex, not grid, and the difference is load-bearing. `place-items:center`
     on a grid leaves the row auto-sized to its content, so the nest's
     `max-height:100%` resolved against the NEST rather than against the stage
     and constrained nothing — on a phone it kept its full viewport-derived
     size and sat straight through the sheet. A flex line's cross size is the
     container's, so the same percentage finally has something real to measure
     against. */
  display:flex;align-items:center;justify-content:center;
  /* Pushed down by half the header's height, so the nest is centred in the
     space BELOW the header rather than in the window — centring it in the
     window is what put the rim through the text. */
  padding-top:clamp(40px,7vh,70px);
  transition:padding .28s cubic-bezier(.2,.7,.3,1);
}

/* Room made for the docked panel, and only while it is open. The panel is
   379px at most and gutters either side of it; the nest gives that up and
   re-centres in what is left. Dismiss the panel and it takes the width back —
   which is the entire argument for the panel being an overlay rather than a
   column, made visible. */
.structure-view.with-detail{
  /* The panel takes at most 380px plus its gutters; the nest is sized against
     what is left rather than against the whole window. */
  --nest:min(calc(96vw - min(380px, 34vw) - clamp(28px,6vw,68px)), 78vh, 860px);
}
.structure-view.with-detail .structure-stage{
  padding-right:calc(min(380px, 34vw) + clamp(28px,6vw,68px));
}

/* The nest. Square by construction so the circles cannot become ellipses at
   any width, and sized against the SHORT edge so a wide-and-short window
   cannot clip it.

   Nearly twice what it was. 70vh capped at 560px meant a 1440p monitor showed
   a 560px diagram in the middle of two thousand pixels of black — the cap was
   sized for a phone and then applied to everything. 88vh with a 900px ceiling
   and a 92vw guard fills a laptop, fills a monitor, and still leaves the
   labels room to ride the outer rim. */
.structure-shells{
  position:relative;
  /* 88vh put the rim under the header at laptop heights. The header is two
     short lines now rather than four, and the stage below carries a matching
     offset, so the object clears it at every height without shrinking back to
     the 560px it was. */
  /* One length, set by --nest, and NOT a width plus max-width/max-height.

     That was tried and it cannot work here: with `aspect-ratio:1` and a
     definite width, a `max-height` clamps the height alone and the ring stops
     being a circle — five concentric ellipses, which is the one thing the
     original comment on this element says must never happen at any size. So
     the available space is computed into the size itself instead, per
     breakpoint and per panel state, and the aspect ratio is never asked to
     satisfy two constraints at once. */
  width:var(--nest);
  aspect-ratio:1;
  /* The labels ride the outermost ring's edge and would otherwise be clipped
     by the stage. */
  overflow:visible;
  /* No filter here, deliberately. A drop-shadow on this element would give the
     object a nice halo and would also make it a containing block for filters,
     which is exactly what breaks the labels: `backdrop-filter` cannot sample
     the canvas behind an ancestor that has its own filter, so every ring name
     would blur nothing. The separation is done by .shell-shade instead, one
     level down, where it costs nothing. */
}

.shell{
  position:absolute;top:50%;left:50%;
  width:var(--shell-size);height:var(--shell-size);
  transform:translate(-50%,-50%);
  border-radius:50%;
  border:1px solid var(--line);
  background:var(--void-2);
  padding:0;cursor:pointer;overflow:hidden;
  /* Inner rings sit above outer ones — that stacking IS the annulus. */
  z-index:calc(var(--shell-depth) + 1);
  transition:border-color .2s ease, box-shadow .2s ease, transform .3s cubic-bezier(.2,.7,.3,1);
}
.shell.is-open{border-color:color-mix(in srgb, var(--epoch-10) 55%, var(--line))}
/* The focused ring is the only bright thing. Everything else recedes rather
   than the focused one merely gaining an outline — with five nested rings an
   outline alone was not findable. */
.structure-shells:not(:hover) .shell:not(.is-focused),
.shell:not(.is-focused){filter:grayscale(.55) brightness(.7)}
/* Sealed, and there is a floor under how dark that is allowed to be. At .38
   the three innermost bands were black discs and the middle of the object was
   a hole — which is the exact failure the centre was just rebuilt to fix,
   reappearing one ring out. Sealed still reads as sealed at .58, because it is
   also desaturated, unlit at the rim and not turning. */
.shell.is-sealed{filter:grayscale(.85) brightness(.62)}
.shell.is-sealed:not(.is-focused){filter:grayscale(.85) brightness(.58)}
.shell.is-focused{
  filter:none;
  border-color:var(--epoch-10);
  box-shadow:0 0 0 1px var(--epoch-10), 0 0 44px -4px var(--epoch-10);
}
.shell:hover{transform:translate(-50%,-50%) scale(1.012)}

/* The photograph. Its own element rather than a background on the button, so
   the art can be positioned, dimmed and TURNED without touching the ring's
   border or its focus ring. */
.shell-face{
  position:absolute;inset:0;border-radius:50%;
  background-size:cover;background-position:center;
  opacity:.62;
  /* Keplerian shear. --shell-spin is set per ring in js/structure.js and goes
     as r^1.5, so the inner bands turn faster than the outer ones and the
     strata slide past each other — that differential is the entire difference
     between a nest of rings and a disk. All the same direction: a disk with
     counter-rotating layers is not a disk, it is a logo.

     On the face, never the button, because the button owns the translate that
     centres it and a rotation on the same element would fight it. */
  animation:shell-spin var(--shell-spin, 40s) linear infinite;
}
.shell.is-focused .shell-face{opacity:.85}
/* A sealed band is not turning. Nothing has ignited it yet, and a ring that
   spins before you have opened it says the place is running without you. */
.shell.is-sealed .shell-face{animation-play-state:paused}

@keyframes shell-spin{ to{ transform:rotate(360deg) } }

/* The shading pass, over the art and under nothing.

   Two jobs in one element. A soft inner shadow around the rim seats each ring
   into the one outside it, so the strata read as depth rather than as five
   flat discs stacked in a paint program. And a one-sided warm lift, brighter
   on the left, standing in for relativistic beaming — the side of a disk
   coming toward you is brighter, and it is the detail that stops a spinning
   texture reading as a loading spinner.

   It does NOT rotate. The beaming asymmetry is a property of which way the
   disk is moving relative to the viewer, so it stays put while the material
   turns underneath it. That contrast is most of why the spin looks physical. */
.shell-shade{
  position:absolute;inset:0;border-radius:50%;pointer-events:none;
  background:
    radial-gradient(circle at 34% 46%, #ffd9a41f, transparent 58%),
    radial-gradient(circle at 50% 50%, transparent 58%, #00000073 100%);
  box-shadow:inset 0 0 26px -6px #000000b3;
}

/* Ignition. A band resolving is the biggest thing that happens down here and
   it used to be a grayscale filter switching off between visits. Light runs
   round the rim once and the ring settles into its new brightness. Added and
   removed by js/structure.js, which only ever fires it on a rebuild that
   FOLLOWS another one — never on the first build of a session. */
.shell.is-igniting{ animation:shell-ignite 2.4s cubic-bezier(.16,.8,.3,1) }
@keyframes shell-ignite{
  0%   { box-shadow:0 0 0 0 var(--epoch-10), 0 0 0 0 #f6e3c000 }
  18%  { box-shadow:0 0 0 2px var(--dust), 0 0 90px 10px #f6e3c07a }
  100% { box-shadow:0 0 0 1px var(--epoch-10), 0 0 44px -4px var(--epoch-10) }
}

/* Per-band art, drawn rather than photographed — for now.

   Real scientific imagery is wanted here and there is genuinely public-domain
   material for most of these scales (bubble-chamber plates and NIST nanoscale
   work are both PD). It is not in yet because every candidate needs its
   SUBJECT checked as well as its licence, and shipping five images that are
   merely nearby-looking would be worse than shipping none. These are built to
   look deliberate rather than absent, so the shape can be judged first.

   Each is the character of its scale: grain is speckle, shell is orbital,
   nucleus is a dense packed core, colour is radiating tracks, the floor is
   noise that never resolves. They are all authored to survive ROTATION now —
   anything with a strong horizontal or vertical axis reads as a wobble once it
   turns, so every one of these is either radial, conic, or isotropic. */
.shell-face[data-band-art="grain"]{
  background-image:
    radial-gradient(1.5px 1.5px at 22% 31%, #cfd6e6aa, transparent 60%),
    radial-gradient(2px 2px at 68% 22%, #b9c2d6aa, transparent 60%),
    radial-gradient(1.5px 1.5px at 41% 68%, #c6cee0aa, transparent 60%),
    radial-gradient(2.5px 2.5px at 78% 71%, #aab4cbaa, transparent 60%),
    radial-gradient(1.5px 1.5px at 15% 82%, #c0c9dcaa, transparent 60%),
    radial-gradient(circle at 50% 45%, #3b4560, #171d2c 78%);
  background-size:34px 34px,44px 44px,29px 29px,52px 52px,38px 38px,100% 100%;
}
/* Two lobes on top of the standing wave, and they are load-bearing rather than
   decorative: the concentric rings underneath are rotationally SYMMETRIC, so
   turning them produces no visible motion at all and this band alone would sit
   dead still while the other four sheared past it. The lobes give the rotation
   something to be measured against. Same reason the floor gets its speckle. */
.shell-face[data-band-art="shell"]{
  background-image:
    radial-gradient(22% 15% at 30% 34%, #b8cbf03d, transparent 70%),
    radial-gradient(16% 26% at 71% 63%, #9fb4e033, transparent 72%),
    repeating-radial-gradient(circle at 50% 50%,
      transparent 0 9px, #8fa7d033 9px 10px, transparent 10px 19px),
    radial-gradient(circle at 50% 50%, #46527a, #141a29 80%);
}
.shell-face[data-band-art="nucleus"]{
  background-image:
    radial-gradient(4px 4px at 46% 48%, #e2b487, transparent 62%),
    radial-gradient(4px 4px at 54% 52%, #cf9a6d, transparent 62%),
    radial-gradient(4px 4px at 50% 43%, #d9a97c, transparent 62%),
    radial-gradient(4px 4px at 43% 55%, #c48f64, transparent 62%),
    radial-gradient(circle at 50% 50%, #2c2438 30%, #14111d 80%);
  background-size:15px 15px,17px 17px,13px 13px,19px 19px,100% 100%;
}
.shell-face[data-band-art="colour"]{
  background-image:
    repeating-conic-gradient(from 0deg at 50% 50%,
      #9fd8e033 0deg 1.4deg, transparent 1.4deg 9deg),
    radial-gradient(circle at 50% 50%, #24405a, #0e1622 82%);
}
.shell-face[data-band-art="floor"]{
  background-image:
    radial-gradient(1px 1px at 30% 40%, #ffffff30, transparent 60%),
    radial-gradient(1px 1px at 72% 66%, #ffffff26, transparent 60%),
    repeating-conic-gradient(from 0deg at 50% 50%,
      #ffffff0d 0deg .6deg, transparent .6deg 2.2deg),
    repeating-radial-gradient(circle at 50% 50%,
      transparent 0 2px, #ffffff0a 2px 3px, transparent 3px 6px),
    radial-gradient(circle at 50% 50%, #1b1b26, #08080d 85%);
  background-size:23px 23px,31px 31px,100% 100%,100% 100%,100% 100%;
}

/* The names, in their own layer ABOVE every ring.

   They used to sit inside their ring at its top edge, which failed twice over:
   the rings are concentric, so all five stacked into one column at twelve
   o'clock and overlapped; and each was clipped by its own ring's
   overflow:hidden and then covered by the ring drawn over it.

   Now they are 72° apart around the circle — no two ever near each other — and
   each rides its own ring's edge, so the label also says which ring it belongs
   to. Positioned from the same --shell-size and --shell-angle the ring uses,
   so a ring and its name cannot drift apart. */
.shell-name{
  position:absolute;z-index:20;
  left:calc(50% + var(--shell-size) / 2 * cos(var(--shell-angle)));
  top:calc(50% + var(--shell-size) / 2 * sin(var(--shell-angle)));
  transform:translate(-50%,-50%);
  display:flex;align-items:center;gap:6px;
  padding:3px 11px 3px 8px;border-radius:100px;
  border:1px solid var(--line);
  /* Sits on top of a moving starfield now, not on flat void, so it needs to
     carry its own ground or the text swims. */
  background:color-mix(in srgb, var(--void) 88%, transparent);
  backdrop-filter:blur(6px);
  cursor:pointer;white-space:nowrap;
  transition:border-color .2s ease, color .2s ease, transform .2s ease;
}
.shell-name-text{
  font-family:"Source Serif 4",serif;font-weight:700;
  font-size:clamp(11px,2.1vw,13px);color:var(--dust);
}
.shell-dot{
  width:6px;height:6px;border-radius:50%;
  background:var(--epoch-10);flex:0 0 auto;
}
.shell-name.is-sealed{opacity:.55}
.shell-name.is-sealed .shell-dot{background:var(--muted)}
.shell-name:hover{transform:translate(-50%,-50%) scale(1.04)}
.shell-name.is-focused{
  border-color:var(--epoch-10);
  box-shadow:0 0 18px -4px var(--epoch-10);
}
.shell-name.is-focused .shell-name-text{color:var(--epoch-10)}

/* ---------- the centre ----------

   The diagram converges on a point, and that point was a number floating in a
   dark hole — the weakest thing on a screen it is the subject of. It is an
   EVENT HORIZON now: a genuinely black disc, and around it the one pure-bright
   element anywhere in this game.

   The brightness budget is the reason it works. Nothing else here is allowed
   to be white — the bands are dimmed, the labels are --dust, the sky is alpha
   white at eight per cent. One unambiguous highlight in a screen with no other
   highlights is what makes the eye fall to the centre, which is the direction
   this entire place is about. */
.shell-core{
  position:absolute;z-index:19;left:50%;top:50%;transform:translate(-50%,-50%);
  display:grid;place-items:center;
  /* 22% of the nest. Comfortably inside the innermost band (which sits at
     45.6%) so the horizon is a thing the rings converge ON, never a sixth
     ring competing with them. */
  width:22%;aspect-ratio:1;
  pointer-events:none;text-align:center;
}

/* The horizon itself, plus its photon ring.

   The ring is drawn slightly ELLIPTICAL and offset, not a perfect circle. A
   perfect circle reads as a UI element — a progress dial, a badge; the flatten
   is what makes it read as a disk seen at an angle. Same reason .shell-shade
   lifts one side: the object is being looked at from somewhere, not head on. */
.shell-horizon{
  position:absolute;inset:0;border-radius:50%;
  /* The shadow. Crisper than it was — the first version faded from 52% to 74%
     and read as a smudge on the lens rather than as an object blocking light.
     A black hole's shadow has a hard edge; that edge is the entire reason the
     ring around it is legible. */
  background:radial-gradient(circle at 50% 50%, #000 0 64%, #04030a 72%, transparent 80%);
}
.shell-horizon::before,
.shell-horizon::after{
  content:"";position:absolute;left:50%;top:50%;border-radius:50%;
  /* .34 was too flat and read as an eye rather than as a disk seen at an
     angle — a shape the game does not want anywhere near its centre. */
  transform:translate(-50%,-50%) scaleY(.52);
}
/* The bright ring. Warm on one side and cooler on the other — the same beaming
   asymmetry the bands carry, so the centre and the strata agree about which
   way the thing is turning. */
/* 150%, not the 118% it started at. At 118 the ring passed straight through
   the Charge total sitting inside it — the one number this screen exists to
   show, with a bright line drawn across the middle of it. The ring is pushed
   out past the text instead of the text being shrunk, because the number is
   the content and the ring is the frame. */
.shell-horizon::before{
  width:150%;height:150%;
  /* Warm, with one cool quarter, and NOT the full epoch spectrum it started
     as. Running all eight epoch colours round the ring made the one focal
     point of the game read as an RGB bezel — the colour was doing decoration
     rather than saying anything. Amber into white with a single cold arc is
     the temperature gradient a real disk has, and it belongs to this game's
     palette instead of announcing itself. */
  background:conic-gradient(from 200deg,
    #fff6e2 0deg, #ffd9a2 54deg, #f0a35e 118deg, #c98cc4 176deg,
    #8fa7d0 214deg, #f0a35e 268deg, #ffd9a2 320deg, #fff6e2 360deg);
  -webkit-mask:radial-gradient(circle at 50% 50%, transparent 0 46%, #000 49% 53%, transparent 57%);
          mask:radial-gradient(circle at 50% 50%, transparent 0 46%, #000 49% 53%, transparent 57%);
  filter:blur(.6px) saturate(1.15);
  animation:core-turn 26s linear infinite;
}
/* Its bloom. Separate element so the glow can be soft while the ring stays a
   hard thin line — one element cannot be both. */
.shell-horizon::after{
  width:196%;height:196%;
  background:radial-gradient(circle at 50% 50%,
    transparent 0 40%, #ffd9a233 52%, #f0a35e1a 64%, transparent 78%);
  filter:blur(9px);
  animation:core-breathe 7s ease-in-out infinite;
}
@keyframes core-turn{ to{ transform:translate(-50%,-50%) scaleY(.52) rotate(360deg) } }
@keyframes core-breathe{ 50%{ opacity:.62 } }

/* The number, inside the horizon. It is the only text in the game sitting on
   true black, so it can afford to be the brightest text in the game. */
.shell-core-inner{
  position:relative;z-index:1;
  display:flex;flex-direction:column;align-items:center;gap:1px;
}
.shell-core-value{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-weight:700;font-size:clamp(17px,3.8vw,26px);color:#fff6e2;line-height:1;
  text-shadow:0 0 18px #f0a35e66;
}
/* The rate, under the total, inside the horizon. It moved down here with the
   total for the same reason: the two numbers belong together, and the thing
   producing them is the thing they are now sitting inside. */
.shell-core-rate{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:clamp(10px,2vw,12px);color:#f6e3c0b8;letter-spacing:.04em;
}

/* ---------- the focused band ----------

   An overlay docked to the right, not the second column of a grid. As a column
   it capped the diagram at half the screen forever and sat in the middle of a
   void whether or not anyone was reading it; as an overlay it arrives when you
   ask a ring a question and leaves when you dismiss it, and the nest gets the
   whole viewport in between. */
.structure-detail{
  position:absolute;z-index:3;
  top:50%;right:clamp(14px,3vw,34px);
  width:min(380px, 34vw);
  max-height:min(74vh, 640px);overflow-y:auto;
  border:1px solid var(--line);border-radius:16px;
  padding:clamp(15px,2vw,22px);
  /* Translucent, because there is a sky behind it now and an opaque slab would
     punch a rectangular hole in the one effect this screen was rebuilt for. */
  background:color-mix(in srgb, var(--void-2) 88%, transparent);
  backdrop-filter:blur(18px) saturate(1.1);
  box-shadow:0 24px 60px -20px #000000cc;
  transform:translate(24px,-50%);opacity:0;
  transition:opacity .24s ease, transform .24s cubic-bezier(.2,.7,.3,1);
}
.structure-detail.show{transform:translate(0,-50%);opacity:1}
/* The house pattern for anything JS toggles `hidden` on: display:none in the
   base rule, restored under :not([hidden]). */
.structure-detail[hidden]{display:none}

/* Below the width where a docked panel would cover the object it describes, it
   becomes a sheet at the bottom instead. Not a media-query afterthought: on a
   phone the nest is already the full width, so there is no "beside" to dock
   to, and covering the bottom third is the only honest place left. */
@media (max-width:860px){
  .structure-detail{
    top:auto;bottom:0;right:0;left:0;
    width:auto;max-height:56vh;
    border-radius:18px 18px 0 0;border-bottom:none;
    transform:translate(0,18px);
  }
  .structure-detail.show{transform:translate(0,0)}

  /* The sheet is bounded so the space above it is worth having. At 56vh the
     nest was left 40% of a phone screen to live in and the two overlapped
     regardless; the panel scrolls internally, so a shorter sheet costs
     nothing but a scroll. */
  .structure-detail{max-height:min(52vh,420px)}

  /* Down here the panel is at the bottom, so the room is made below rather
     than beside — and only PART of it.

     Making the full sheet's worth of room is what the desktop rule does and it
     is wrong on a phone: subtracting 52vh from an already short viewport left
     a 150px diagram, which is not an object you can look at. The nest is
     shifted up and sized so its CENTRE — the horizon, the number, the whole
     subject — sits well clear of the sheet, and the bottom of the outer ring
     is allowed to run under it. The sheet is translucent and blurred, so what
     that reads as is depth rather than as clipping. */
  /* `.structure-view.with-detail` outranks a bare `.structure-view` whatever
     media query it is in, so the override has to carry the same class or the
     desktop formula — which subtracts a side panel that is not there — would
     go on deciding the size down here. */
  .structure-view.with-detail{--nest:min(92vw, 52vh, 860px)}
  .structure-view.with-detail .structure-stage{
    padding-right:0;
    /* Both edges, not just the bottom. Making room only below re-centres the
       nest into the header, which on a short viewport is a ring drawn through
       the words telling you where you are. */
    padding-top:clamp(70px,12vh,104px);
    padding-bottom:34vh;
  }
}

.band-dismiss{
  position:absolute;top:8px;right:8px;
  width:var(--tap);height:var(--tap);
  display:grid;place-items:center;
  background:none;border:none;cursor:pointer;
  color:var(--muted);font-size:22px;line-height:1;
}
.band-dismiss:hover{color:var(--dust)}

.band-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding-right:34px}
.band-name{
  font-family:"Source Serif 4",serif;font-weight:700;
  font-size:clamp(18px,4.4vw,24px);color:var(--dust);line-height:1.1;
}
.band-scale{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--muted);
}
.band-state{
  margin-left:auto;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--state-affordable);
}
.band-state.is-locked{color:var(--muted)}
.band-flavour{margin:9px 0 0;font-size:clamp(12px,2.7vw,13px);line-height:1.55;color:var(--muted)}
.band-flavour.is-sealed{font-style:italic}

.band-nodes{display:flex;flex-direction:column;gap:8px;margin-top:15px}

/* A node. One-off, permanent, never a count — so there is no quantity control
   and no stepper, which is most of what separates this from the tree's card
   and the shop's at a glance. */
.struct-node{
  display:flex;flex-direction:column;align-items:flex-start;gap:4px;
  padding:11px 13px;border-radius:10px;text-align:left;
  border:1px solid var(--line);background:color-mix(in srgb, var(--void) 82%, transparent);
  cursor:pointer;transition:border-color .16s ease, filter .16s ease;
}
.struct-node.is-affordable{border-color:var(--state-affordable)}
.struct-node.is-affordable:hover{filter:brightness(1.1)}
.struct-node.is-resolved{opacity:.5;cursor:default}
.struct-node.is-bridge{border-style:dashed}
.struct-name{
  font-family:"Source Serif 4",serif;font-weight:700;
  font-size:clamp(13px,3vw,15px);color:var(--dust);
}
.struct-desc{font-size:11.5px;line-height:1.5;color:var(--muted)}
.struct-cost{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--epoch-10);margin-top:2px;
}
.struct-node.is-affordable .struct-cost{color:var(--state-affordable)}
.struct-node.is-resolved .struct-cost{color:var(--muted)}

/* Reduced motion. js/horizon.js already answers for the canvas by painting one
   correctly-lensed frame and stopping; this is everything the CSS is doing.

   Stopped, not slowed. Someone who asked the operating system to stop moving
   things did not ask for a gentler version of the movement. Every one of these
   is decoration over a static composition that is still completely readable
   without it — which is the test for whether an animation was allowed to exist
   in the first place. */
@media (prefers-reduced-motion:reduce){
  .shell-face,
  .shell-horizon::before,
  .shell-horizon::after,
  .shell.is-igniting{animation:none}
  .structure-detail{transition:none}
}

/* ---------- the season pass ----------

   A track, not a grid. The shop is a set of independent things you choose
   between; this is one ordered thing you move along, and the layout has to say
   so — hence rows with a tier number down the left and two fixed columns, at
   every width. Reflowing it into cards on a phone would lose the single fact
   the whole screen exists to communicate: which of these two columns you are
   getting, and which you are not.

   The premium column is drawn for people who have not bought it. That is the
   point of a free track and it cannot do its job otherwise — it is dimmed, not
   hidden, and never blurred, because obscuring what someone might buy is a way
   of making it look better than it is. */
.pass-toggle{position:relative}
/* display:none in the base rule, restored under :not([hidden]) — the house
   pattern for anything JS toggles the `hidden` attribute on. A bare
   `display:flex` here would beat `[hidden]` and the badge would never go away
   when the last reward was collected. */
.pass-count{
  display:none;
  position:absolute;top:-3px;right:-3px;min-width:17px;height:17px;
  align-items:center;justify-content:center;padding:0 4px;
  border-radius:100px;background:var(--state-affordable);color:var(--void);
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;font-weight:700;line-height:1;
}
.pass-count:not([hidden]){display:flex}
.pass-panel{max-width:min(900px,95vw)}
.pass-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap}
.pass-closing{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);
}

.pass-summary{
  display:flex;flex-direction:column;gap:10px;
  padding:clamp(12px,3vw,18px);margin-bottom:clamp(16px,3vw,22px);
  border:1px solid var(--line);border-radius:14px;background:var(--panel);
}
.pass-tier-now{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}
.pass-tier-label{
  font-family:"Space Grotesk",sans-serif;font-size:10px;
  letter-spacing:.14em;text-transform:uppercase;color:var(--muted);
}
.pass-tier-value{
  font-family:"Source Serif 4",serif;font-weight:800;
  font-variant-numeric:tabular-nums;
  font-size:clamp(22px,5.5vw,30px);color:var(--dust);line-height:1;
}
.pass-tier-of{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--muted);
}
.pass-bar{height:7px;border-radius:100px;background:var(--void-2);overflow:hidden}
.pass-bar-fill{height:100%;background:var(--accent);border-radius:100px;transition:width .3s ease}
.pass-owned{margin:0;font-size:12px;color:var(--state-affordable)}

.pass-buy{
  display:flex;align-items:baseline;justify-content:center;gap:10px;flex-wrap:wrap;
  padding:11px 16px;border-radius:100px;border:0;
  background:var(--accent);color:var(--void);
  font-family:"Source Serif 4",serif;font-weight:800;
  font-size:clamp(13px,3vw,15px);
  transition:filter .16s ease, transform .16s ease;
}
.pass-buy:hover{filter:brightness(1.08)}
.pass-buy:active{transform:translateY(1px)}
.pass-buy-price{font-variant-numeric:tabular-nums}
/* Stated because it is the true reason to buy now rather than later: buying
   hands over everything already earned. */
/* What stands where the buy button would be when nothing is for sale. Plain
   prose, no border, no affordance — it must not read as a thing to press. */
.pass-soon{
  margin:0;font-size:12px;line-height:1.55;color:var(--muted);
}
.pass-buy-waiting{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;font-weight:400;opacity:.75;
}

.pass-track{
  display:flex;flex-direction:column;gap:5px;
  /* Taller on a tall screen. 56vh was a fixed slice that showed six rows of
     forty however much room there was. */
  max-height:clamp(300px, 62vh, 760px);
  overflow-y:auto;padding-right:4px;
}

/* The column headers. Sticky because the track is forty rows: the whole point
   is that "which of these do I actually get" stays answerable at tier 30. */
.pass-row-head{
  position:sticky;top:0;z-index:1;
  opacity:1;padding:6px 0 7px;margin-bottom:1px;
  background:var(--void-2);
  border-bottom:1px solid var(--line);
}
.pass-col-head{
  display:flex;align-items:center;gap:6px;padding-left:10px;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);
}
.pass-col-head.is-premium{color:var(--accent)}
.pass-col-lock{
  font-size:9px;letter-spacing:.1em;color:var(--muted);
  border:1px solid currentColor;border-radius:100px;padding:1px 6px;
}
.pass-row-head .pass-row-tier{
  font-size:9px;letter-spacing:.14em;color:var(--muted);
}

/* Where you are. One row of forty carries this, and without it the track was
   forty near-identical rows with no "here" in them. */
.pass-row.is-current{opacity:1}
.pass-row.is-current .pass-cell{border-color:color-mix(in srgb, var(--accent) 55%, var(--line))}
.pass-row.is-current .pass-row-tier{
  color:var(--accent);font-weight:700;
}

.pass-tier-next{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--muted);margin-left:auto;
}
.pass-ready{
  margin:0;font-size:12px;line-height:1.5;color:var(--state-affordable);
}
.pass-row{
  display:grid;grid-template-columns:34px 1fr 1fr;gap:5px;align-items:stretch;
  opacity:.5;transition:opacity .2s ease;
}
.pass-row.is-reached{opacity:1}
.pass-row-tier{
  display:flex;align-items:center;justify-content:center;
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--muted);
}
.pass-cell{
  display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:2px;
  min-height:44px;padding:7px 10px;border-radius:9px;text-align:left;
  border:1px solid var(--line);background:var(--void-2);
  font-family:"Source Serif 4",serif;
}
.pass-cell.is-empty{border-style:dashed;opacity:.3}
.pass-cell.is-premium{border-color:color-mix(in srgb, var(--accent) 45%, transparent)}
.pass-cell.is-locked{opacity:.55}
.pass-cell.is-claimed{opacity:.45}
.pass-cell.is-claimable{
  border-color:var(--state-affordable);
  box-shadow:0 0 0 1px color-mix(in srgb, var(--state-affordable) 40%, transparent);
  cursor:pointer;
}
.pass-cell.is-claimable:hover{filter:brightness(1.12)}
.pass-cell.is-exclusive{border-color:var(--accent)}
.pass-cell-label{
  font-size:clamp(11px,2.5vw,13px);font-weight:700;color:var(--dust);
  font-variant-numeric:tabular-nums;line-height:1.25;
}
.pass-cell-tag,
.pass-cell-state{
  font-family:"Space Grotesk",sans-serif;
  font-size:9px;letter-spacing:.1em;text-transform:uppercase;
}
.pass-cell-tag{color:var(--accent)}
.pass-cell-state{color:var(--muted)}
.pass-cell.is-claimable .pass-cell-state{color:var(--state-affordable)}

/* ---------- the starter pack ----------

   Above the ladder, not on it. The gem packs are a row of equals whose only
   difference is size; this is one card, wider than any of them, and it has to
   read as a different KIND of thing at a glance or it just looks like a sixth
   pack that happens to be cheaper — which would make the ladder beside it
   look like a swindle.

   So: a border in the accent rather than a tint of it, a flag that says how
   long it lasts, and its worth stated next to its price. Everything the row
   below deliberately does not do. */
.starter-section{margin-bottom:clamp(30px,6vw,58px)}
.starter-card{
  display:flex;flex-direction:column;gap:10px;
  padding:clamp(16px,3.5vw,24px);border-radius:16px;
  border:1px solid var(--accent);
  background:
    radial-gradient(circle at 100% 0%, var(--accent-soft), transparent 58%),
    var(--panel);
  box-shadow:0 26px 46px -30px var(--accent);
}
.starter-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
.starter-flag{
  font-family:"Space Grotesk",sans-serif;font-size:10px;
  letter-spacing:.14em;text-transform:uppercase;color:var(--accent);
  border:1px solid currentColor;border-radius:100px;padding:2px 9px;
}
/* The countdown is information, not pressure — muted, and the same size as
   the flag it sits opposite. tabular-nums so "9 days left" and "10 days left"
   don't shift the flag beside them as the week runs down. */
.starter-closes{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);
}
.starter-name{
  font-family:"Source Serif 4",serif;font-weight:800;
  font-size:clamp(19px,5vw,26px);color:var(--dust);margin:0;line-height:1.1;
}
.starter-desc{font-size:clamp(12px,2.7vw,14px);color:var(--muted);margin:0;line-height:1.5}
.starter-contents{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}
.starter-gems{
  font-family:"Source Serif 4",serif;font-weight:800;
  font-variant-numeric:tabular-nums;
  font-size:clamp(17px,4.2vw,23px);color:var(--dust);line-height:1;
}
.starter-extras{font-size:clamp(11px,2.5vw,13px);color:var(--muted)}
.starter-buy{
  display:flex;align-items:baseline;justify-content:center;gap:10px;flex-wrap:wrap;
  margin-top:4px;padding:12px 18px;border-radius:100px;
  border:0;background:var(--accent);color:var(--void);
  font-family:"Source Serif 4",serif;font-weight:800;
  font-size:clamp(14px,3.2vw,17px);
  transition:transform .16s ease, filter .16s ease;
}
.starter-buy:hover{filter:brightness(1.08)}
.starter-buy:active{transform:translateY(1px)}
.starter-price{font-variant-numeric:tabular-nums}
/* Stated, never struck through. A strikethrough on a price the player was
   never going to pay is the oldest trick in the shop, and this bundle is a
   good enough deal to survive being described honestly. */
.starter-worth{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:clamp(10px,2.2vw,12px);font-weight:400;opacity:.72;
}

/* ---------- cursors ----------
   Inline SVG data URIs, not image files: this project has no build step and
   must run from disk, so an asset that needs fetching is an asset that can
   fail. Each ends with a keyword fallback for anywhere the data URI is
   refused, and the hotspot coordinates are stated explicitly — omitting them
   puts the click point at the image's top-left corner, which feels broken.

   Fine-pointer only. A touch device has no cursor, and a coarse pointer with
   a 24px ring drawn under it would just be wrong. */
@media (hover:hover) and (pointer:fine){
  /* The default mark is an ARROW, because a bare ring reads as a target
     reticle and left people unsure what was actually being pointed at. This
     one is a swept, back-raked arrowhead — a probe rather than an office
     cursor — with a dark outline under a light fill so it survives against
     both the deep field and a white panel. Hotspot at the very tip (0 0). */
  body{
    cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26"><path d="M1 1 L1 18.5 L6.1 13.9 L9.2 21.4 L12.6 19.9 L9.5 12.6 L16.4 12.2 Z" fill="%23120d1a" stroke="%23120d1a" stroke-width="2.6" stroke-linejoin="round"/><path d="M1 1 L1 18.5 L6.1 13.9 L9.2 21.4 L12.6 19.9 L9.5 12.6 L16.4 12.2 Z" fill="%23f6e3c0"/></svg>') 1 1, default;
  }
  /* Anything you can press gets the same arrow with a small ignition spark at
     its tail — the pointer answers "can I click this?" without needing a
     hover style on every element, and the silhouette stays constant so the
     shape never jumps as you move across the screen. */
  button, .tree-node, .sky-node, .tree-portal, a, summary, [role="button"]{
    cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><path d="M1 1 L1 18.5 L6.1 13.9 L9.2 21.4 L12.6 19.9 L9.5 12.6 L16.4 12.2 Z" fill="%23120d1a" stroke="%23120d1a" stroke-width="2.6" stroke-linejoin="round"/><path d="M1 1 L1 18.5 L6.1 13.9 L9.2 21.4 L12.6 19.9 L9.5 12.6 L16.4 12.2 Z" fill="%23f0a35e"/><circle cx="20.5" cy="20.5" r="3.4" fill="%23f0a35e" opacity="0.3"/><circle cx="20.5" cy="20.5" r="1.5" fill="%23f0a35e"/></svg>') 1 1, pointer;
  }
  /* Selecting text keeps a real I-beam, but struck in the game's own ink
     rather than the OS default, so the one place you read closely doesn't
     suddenly look like a form on a different website. */
  input, textarea, .guide-panel, .codex-flavour, .sheet-flavour, .save-hint, .friend-code{
    cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="24"><path d="M4 2h8M4 22h8M8 2v20" stroke="%23120d1a" stroke-width="4.2" fill="none" stroke-linecap="round"/><path d="M4 2h8M4 22h8M8 2v20" stroke="%23f6e3c0" stroke-width="1.6" fill="none" stroke-linecap="round"/></svg>') 8 12, text;
  }
  /* The tree keeps grab/grabbing: on a surface you drag, the hand is a
     stronger signal than any custom mark, and it changes on press. */
  .tree-viewport, .sky-viewport{cursor:grab}
  .tree-viewport.dragging, .sky-viewport.dragging{cursor:grabbing}
  /* NOTE: the old `input, textarea{cursor:text}` lived here and silently beat
     the custom I-beam above it — later rule, same specificity. Don't put a
     bare keyword cursor after the block. */
  button:disabled{cursor:not-allowed}
}

/* ---------- the deep background ----------
   Three slow-drifting nebula clouds behind the starfield. They use the epoch
   accent, so the sky warms and cools as you advance without any JS: the
   --epoch-N variables remain the single source of truth for colour.
   Sits at z-index 0, below the stars (1) and everything else. */
.nebula{
  position:fixed;inset:-20%;z-index:0;pointer-events:none;
  opacity:.5;
  background:
    radial-gradient(38% 30% at 22% 26%, var(--accent-soft), transparent 68%),
    radial-gradient(32% 26% at 78% 62%, var(--fog-deep), transparent 70%),
    radial-gradient(46% 34% at 52% 88%, var(--fog-warm), transparent 72%);
  filter:blur(28px);
  animation:nebula-drift 90s ease-in-out infinite alternate;
}
@keyframes nebula-drift{
  from{transform:translate3d(-1.5%,-1%,0) scale(1)}
  to{transform:translate3d(1.5%,1.5%,0) scale(1.06)}
}

/* ---------- the deep field ----------
   Three layers of distant matter that parallax against the tree. Each declares
   its own --depth here; onTransform() in js/render.js reads that once and
   writes the layer's transform directly. Far things move least, which is the
   whole trick — the tree stops reading as a drawing on a flat backdrop and
   starts reading as something suspended in depth.

   The depth belongs in this file and the arithmetic belongs in that one. What
   must NOT come back is the arrangement where a shared --par-x/y was written
   onto <body> and multiplied inside each layer's own calc(): it invalidated
   style for the entire document on every frame of every drag, and a transform
   made of calc(var(...)) can never reach the compositor. See the comment on
   onTransform().

   Distinct from .starfield on purpose: those are pin-sharp foreground stars,
   these are soft, larger, dimmer smudges — distant galaxies and clusters. */
/* The fixed sky. Sits under everything and never moves; the parallax layers,
   the murals and the tree all travel across it. Attribution and rights for the
   photograph are in images/epochs/ATTRIBUTION.md. */
/* The sky was shipped at full strength and it was far too loud: a deep field
   is thousands of bright galaxies, and at full contrast every one of them
   competed with a node for attention. It is the floor of the room, so it is
   treated like one — dimmed hard, blurred just past the point where individual
   galaxies read as objects, and vignetted so the centre of the screen (where
   the tree is) is the quietest part of it. */
.sky{
  position:fixed;inset:-40px;z-index:-1;pointer-events:none;
  background:var(--void) url("../images/sky.jpg") center/cover no-repeat;
  filter:blur(3px) brightness(.5) saturate(.75);
  opacity:.55;
}
.sky::after{
  content:"";position:absolute;inset:0;
  background:radial-gradient(ellipse 78% 72% at 50% 46%,
    color-mix(in srgb, var(--void) 78%, transparent) 0%,
    color-mix(in srgb, var(--void) 40%, transparent) 55%,
    transparent 100%);
}
/* On the light theme the same photograph would be a black slab in the middle
   of a paper-white chart, so it is dropped rather than dimmed. */
html[data-theme="light"] .sky{background-image:none}

/* The epoch murals, printed rather than projected.
   
   These had NO light treatment at all, which is the single thing that made the
   light theme look broken: ten nebula photographs at .28 alpha-blending onto
   cream, which is not a faint image on paper, it is milk poured over the map.
   Every node name and every region label was then read through it, and the
   contrast the ink palette was carefully built for was spent before it reached
   the eye.
   
   The first fix here inverted them, which was the right answer to a near-white
   ground: alpha-blending a bright photo onto cream can only ever LIGHTEN it,
   so the structure had to be turned into dark filaments to exist at all.
   
   The ground is no longer near-white, and that changes the answer. A mid-value
   slate has room ABOVE and BELOW it, so the photograph can simply be itself —
   its dark body sinks, its bright fronts lift, and the mural reads as depth
   rather than as a stain. Inverting now would be solving a problem the theme
   no longer has, and it would put the nebula's glow where its emptiness is.
   
   Desaturated rather than left in colour, because the palette above went to
   some trouble deciding what colour this map is, and ten photographs are not
   entitled to overrule it. */
html[data-theme="light"] .epoch-mural{
  opacity:.26;
  filter:grayscale(.85) contrast(1.05);
}

/* Three black glows around dark ink. Correct over a photograph of space, and
   a smudge on paper — it was fighting the very contrast that makes the light
   theme readable. The halo is the paper instead, which is what separates text
   from a busy ground in print. */
html[data-theme="light"] .tree-node-name{
  text-shadow:0 0 4px var(--void-2), 0 0 9px var(--void-2), 0 1px 2px var(--void-2);
}

/* Same problem, same fix: a black outline under a connector exists so a thin
   line survives a bright mural. On paper the line is already the darkest thing
   present and the outline only muddies it. */
html[data-theme="light"] .tree-lines path{
  filter:drop-shadow(0 0 2px var(--void));
}

/* `contain` so the three layers inside can move without the browser having to
   consider whether anything outside this box was affected. They are decoration
   behind a full-screen map; nothing here can ever influence the layout or the
   paint of anything else, and saying so explicitly is what keeps a pan from
   walking the rest of the document. */
.deep-field{
  position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;
  contain:layout paint size style;
}
.deep-layer{
  position:absolute;inset:-12%;
  will-change:transform;
}

.deep-far{
  opacity:.32;
  --depth:.012;
  background:
    radial-gradient(7px 3px at 14% 18%, var(--fog-deep), transparent 60%),
    radial-gradient(5px 2px at 63% 11%, var(--fog-cool), transparent 60%),
    radial-gradient(9px 4px at 82% 44%, var(--fog-deep), transparent 62%),
    radial-gradient(6px 3px at 31% 57%, var(--fog-warm), transparent 60%),
    radial-gradient(8px 3px at 52% 79%, var(--fog-cool), transparent 60%),
    radial-gradient(5px 2px at 9% 86%, var(--fog-deep), transparent 60%);
}
.deep-mid{
  opacity:.42;
  --depth:.03;
  background:
    radial-gradient(14px 5px at 24% 33%, var(--fog-warm), transparent 66%),
    radial-gradient(11px 4px at 71% 24%, var(--fog-deep), transparent 64%),
    radial-gradient(16px 6px at 47% 62%, var(--fog-cool), transparent 68%),
    radial-gradient(12px 5px at 88% 71%, var(--fog-warm), transparent 64%);
}
/* The near layer carries the epoch's own colour, so the closest matter is the
   thing that most obviously changes as the universe advances. */
.deep-near{
  opacity:.3;
  --depth:.06;
  background:
    radial-gradient(120px 44px at 18% 72%, var(--accent-soft), transparent 70%),
    radial-gradient(90px 34px at 78% 22%, var(--accent-soft), transparent 70%);
  filter:blur(2px);
}

/* A single slow band of light across the sky. One element, one animation —
   the restraint is deliberate: this sits behind everything a player is
   actually reading. */
.aurora{
  position:fixed;left:-25%;right:-25%;top:22%;height:38vh;z-index:0;
  pointer-events:none;opacity:.16;
  background:linear-gradient(100deg, transparent 8%, var(--accent) 38%, var(--epoch-7) 58%, transparent 88%);
  filter:blur(60px);
  transform:rotate(-7deg);
  animation:aurora-sway 42s ease-in-out infinite alternate;
}
@keyframes aurora-sway{
  from{transform:rotate(-9deg) translate3d(-4%,-2%,0) scaleY(.9)}
  to{transform:rotate(-4deg) translate3d(4%,3%,0) scaleY(1.15)}
}

/* ============================================================
   LIGHT THEME — "the star chart"

   The game is designed dark and stays dark by default. Light is not an
   inversion of it: a photograph of deep space cannot be turned inside out and
   still mean anything. It's a different object — an astronomical chart
   printed on paper, with the same shapes and the same information in ink.

   This works at all because the stylesheet never hardcodes a colour outside
   :root. Everything below is a variable override; not one rule is repeated.
   Anything new that hardcodes a hex will simply be wrong here, which is a
   useful way to catch it.
   ============================================================ */
/* DAYLIGHT, not paper.
   
   This was near-white cream, and near-white was the problem: at that value
   there is almost no room left above the ground, so anything pale — a mural,
   a node's photograph, a glow — could only wash out, and the whole theme read
   as the dark one with the lights left on. A ground with some actual value in
   it gives the art somewhere to sit and gives light effects somewhere to go.
   
   Cool, because the accent is warm. A slate ground under an orange accent is
   the oldest legible pairing there is, and it means the one colour that has to
   be found instantly on this screen is the one colour nothing else is near. */
html[data-theme="light"]{
  --void:#c9d0dc;              /* daylight — the ground */
  --void-2:#dfe4ec;            /* cards, panels, the raised surfaces */
  --line:#a3aec1;
  --dust:#161b26;              /* ink — this is the text colour */
  --muted:#4e586c;
  --accent:#9c4413;
  --accent-soft:#9c441326;

  /* The ten epochs, re-struck against a mid-value cool ground. They are darker
     than the paper set they replace: on cream a mid-tone carried itself, and
     on slate the same colour is only a shade away from the ground it is
     supposed to stand on. Same warm-to-cool journey, same order. */
  --epoch-1:#8f4212;
  --epoch-2:#963320;
  --epoch-3:#8e1f42;
  --epoch-4:#74183a;
  --epoch-5:#53226f;
  --epoch-6:#332788;
  --epoch-7:#08505d;
  --epoch-8:#2a3880;
  --epoch-9:#434b68;
  --epoch-10:#2b3044;

  --state-owned:#69718a;
  --state-affordable:#0e5c31;
  --state-available:#154886;
  --state-available-soft:#15488633;

  /* The atmosphere layers, cooled to match. These tint the ground rather than
     lighting it, which is the whole difference between this theme and the one
     it replaces. */
  --fog-warm:#9aa3b84d;
  --fog-cool:#8f9cba46;
  --fog-deep:#7e8aa842;
  --sky-glow:#b9c6d8;
  --light-bright:#08505d;
  --scrim:#232a38;

  /* The Canvas, a shade below the ground so the map reads as a surface laid on
     it. The other three are var(--epoch-N) references and follow the block
     above automatically. */
  --canvas-void:#bcc4d2;

  /* A chart still separates its spectral classes — that is what the colour is
     FOR — so the classes survive the theme, deepened against the new ground
     rather than inverted. */
  --spec-o:#22409f;
  --spec-b:#2c4aa8;
  --spec-a:#455796;
  --spec-f:#5c5f70;
  --spec-g:#75592c;
  --spec-k:#93591c;
  --spec-m:#9e3714;
}

/* The starfield's dots are literal white — the one documented exception to
   the no-hex rule, because nine alpha-white dots inside one gradient list
   would be unreadable as variables. Inverting the layer turns them into ink
   specks on paper instead, which is exactly what a chart wants. */
html[data-theme="light"] .starfield{filter:invert(1);opacity:.55}

/* Deep space glows; paper doesn't. These layers are what would give the
   theme away as a recolour, so they're pulled right back. */
html[data-theme="light"] .nebula{opacity:.3;filter:blur(38px)}
html[data-theme="light"] .aurora{opacity:.1}
html[data-theme="light"] .deep-far{opacity:.22}
html[data-theme="light"] .deep-mid{opacity:.26}
html[data-theme="light"] .deep-near{opacity:.2}
html[data-theme="light"] .epoch-fog{opacity:.35!important}

/* On paper the core is the one thing still lit from within, so it keeps its
   dark interior — a bright disc on a bright page would vanish. */
html[data-theme="light"] .core{box-shadow:0 0 50px -18px var(--accent)}
html[data-theme="light"] .core-label{color:var(--void-2);text-shadow:0 1px 2px #00000055}

/* The theme picker itself */
.theme-choice{display:flex;gap:8px;flex:0 0 auto}
.theme-choice button{
  background:none;border:1px solid var(--line);color:var(--muted);
  font:inherit;font-size:12px;padding:9px 14px;border-radius:6px;cursor:pointer;
  transition:border-color .15s ease, color .15s ease, background .15s ease;
}
.theme-choice button:hover{border-color:var(--accent);color:var(--accent)}
.theme-choice button[aria-pressed="true"]{
  border-color:var(--accent);background:var(--accent-soft);color:var(--accent);
}

/* ============================================================
   CONTROL SIZING — one place, so it can't drift
   ============================================================

   Each of these already has its own rule above with its own look; this block
   only guarantees the box is big enough to hit. Keeping it together means the
   next control someone adds gets checked against a list rather than against
   memory, and it means the floor can be raised for every control at once by
   editing --tap.

   Rule of thumb for anything new: if it's a word, give it min-height. If it's
   a glyph, give it both dimensions and centre the glyph — a 20px "x" with 4px
   of padding is a 28px target however big the character looks. */

/* Text and pill buttons. */
.qty-btn,
.drawer-link,
.collapse-btn,
.collapse-cancel, .collapse-confirm-btn,
.account-submit, .account-toggle,
.firmament-back,
.setting-row > button,
.save-actions button,
.update-banner-btn,
.realign-btn, .socket-btn,
.theme-choice button{
  min-height:var(--tap);
}

/* The range input's own box, so the thumb has a full-height strip to be
   grabbed in rather than a 5px track. */
.socket-slider{height:var(--tap)}

/* Inputs, which need the same floor — and 16px specifically, because iOS
   Safari zooms the whole page in when a focused field's text is smaller. */
.account-field input, .account-inline input{
  min-height:var(--tap);
  font-size:16px;
}

/* Glyph buttons: square, and the glyph centred in it rather than sitting in a
   corner of a box that grew around it. `padding:0` undoes the old 4px, which
   would otherwise fight place-content. */
.sheet-close, .drawer-close, .guide-close, .update-banner-close{
  width:var(--tap);height:var(--tap);padding:0;
  display:grid;place-content:center;
  border-radius:10px;
}
/* They were positioned as small glyphs, so the bigger box needs pulling back
   into the corner it used to sit in. */
.sheet-close, .drawer-close{top:8px;right:10px}
.guide-close{top:6px;right:8px}

/* The exception: the first-buy hint is a small pill and a 46px close would be
   taller than the thing it closes. The visible glyph stays small and the HIT
   AREA is extended past it instead — the standard fix when layout genuinely
   can't give a control more room. */
.first-buy-hint-close{position:relative}
.first-buy-hint-close::after{
  content:"";position:absolute;inset:-12px;
}

/* ============================================================
   COSMETICS — what a purchase actually looks like

   The server owns what an item costs and who owns it; this file owns how it
   looks. That split is why adding a cosmetic touches exactly two files:
   api/config/shop.php for the price, and this block for the appearance.

   Everything keys off data attributes on <html>, written by applyCosmetics()
   in js/social.js from the server's `equipped` map. One attribute per slot,
   so wearing a skin and a palette and a shape set at once needs no combining
   logic — they simply don't collide.
   ============================================================ */

/* ---------- core skins ---------- */
html[data-skin="skin_ember"] .core,
.spec-orb[data-skin="skin_ember"]{
  background:radial-gradient(circle at 50% 45%, #ffd39a 0%, #e8622a 42%, #6b1d10 74%, var(--void-2) 92%);
  box-shadow:0 0 70px -10px #e8622a;
}
html[data-skin="skin_void"] .core,
.spec-orb[data-skin="skin_void"]{
  background:radial-gradient(circle at 50% 45%, #2a2338 0%, #120d1a 55%, #05030a 78%);
  box-shadow:0 0 60px -14px #8f7fd4, inset 0 0 22px -6px #b7a8ff;
  border-color:#b7a8ff;
}
html[data-skin="skin_void"] .core-label,
.spec-orb[data-skin="skin_void"] .spec-orb-label{color:#d8ccff;text-shadow:none}
html[data-skin="skin_aurora"] .core,
.spec-orb[data-skin="skin_aurora"]{
  background:radial-gradient(circle at 50% 45%, #b8ffe6 0%, #45d6a0 34%, #6b4fd0 68%, var(--void-2) 90%);
  box-shadow:0 0 70px -10px #45d6a0;
}
html[data-skin="skin_pulsar"] .core,
.spec-orb[data-skin="skin_pulsar"]{
  background:radial-gradient(circle at 50% 45%, #ffffff 0%, #9fd8ff 30%, #2b5fa8 62%, var(--void-2) 88%);
  box-shadow:0 0 74px -8px #9fd8ff;
}
/* The pulsar earns its name: a beam sweeps the disc. Inherits the global
   prefers-reduced-motion rule with everything else. */
html[data-skin="skin_pulsar"] .core::after,
.spec-orb[data-skin="skin_pulsar"]::after{
  content:"";position:absolute;inset:0;border-radius:50%;
  background:conic-gradient(from 0deg, transparent 0deg, #ffffffaa 12deg, transparent 30deg, transparent 360deg);
  animation:pulsar-sweep 2.4s linear infinite;pointer-events:none;
}
@keyframes pulsar-sweep{to{transform:rotate(360deg)}}
html[data-skin="skin_goldleaf"] .core,
.spec-orb[data-skin="skin_goldleaf"]{
  background:radial-gradient(circle at 50% 45%, #fff3c4 0%, #e0b040 38%, #8a6110 70%, var(--void-2) 90%);
  box-shadow:0 0 66px -10px #e0b040;
  border-color:#f0d68a;
}
html[data-skin="skin_goldleaf"] .core-label,
.spec-orb[data-skin="skin_goldleaf"] .spec-orb-label{color:#4a3405;text-shadow:0 1px 0 #ffffff66}
html[data-skin="skin_singularity"] .core,
.spec-orb[data-skin="skin_singularity"]{
  background:radial-gradient(circle at 50% 45%, #000000 0%, #000000 44%, #6b4a9e 60%, #d9c4ff 68%, var(--void-2) 86%);
  box-shadow:0 0 80px -6px #a98bff, inset 0 0 30px 6px #000;
  border-color:#d9c4ff;
}
html[data-skin="skin_singularity"] .core-label,
.spec-orb[data-skin="skin_singularity"] .spec-orb-label{color:#e6dbff;text-shadow:0 0 6px #000}

/* Nebula: two offset clouds rather than one radial, so the centre of light and
   the centre of the sphere don't coincide — which is what stops it reading as
   a billiard ball. */
html[data-skin="skin_nebula"] .core,
.spec-orb[data-skin="skin_nebula"]{
  background:
    radial-gradient(circle at 34% 32%, #ffd7f2 0%, #b268d6 26%, transparent 62%),
    radial-gradient(circle at 68% 66%, #6fd0ff 0%, #2b4a9e 34%, transparent 70%),
    radial-gradient(circle at 50% 45%, #43206b 0%, var(--void-2) 88%);
  box-shadow:0 0 70px -12px #b268d6;
  border-color:#d8a8ff;
}
html[data-skin="skin_nebula"] .core-label,
.spec-orb[data-skin="skin_nebula"] .spec-orb-label{color:#f6e4ff;text-shadow:0 0 6px #2a1140}

/* Iron: banded rather than smooth. The repeating-linear-gradient is the whole
   idea — a shell structure, seen edge on. */
html[data-skin="skin_iron"] .core,
.spec-orb[data-skin="skin_iron"]{
  background:
    repeating-linear-gradient(118deg, #6e6a66 0 6px, #55514e 6px 12px, #7d7873 12px 17px),
    radial-gradient(circle at 50% 45%, #9b958f 0%, #3a3734 76%, var(--void-2) 96%);
  background-blend-mode:soft-light, normal;
  box-shadow:0 0 44px -16px #b9b2aa, inset 0 -10px 24px -8px #000;
  border-color:#9b958f;
}
html[data-skin="skin_iron"] .core-label,
.spec-orb[data-skin="skin_iron"] .spec-orb-label{color:#1d1b19;text-shadow:0 1px 0 #ffffff55}

/* Season 1, tier 10. Pass-exclusive: never sold, so it never gets a shop card
   — but it still needs the .spec-orb selector, because the pass draws its
   rewards with the same specimen elements the shop uses. One look, one place
   to change it. */
html[data-skin="skin_magnetar"] .core,
.spec-orb[data-skin="skin_magnetar"]{
  background:
    conic-gradient(from 210deg, #6ff0ff 0deg, #1d4a8a 70deg, #0b1740 150deg, #7a4ff0 240deg, #6ff0ff 360deg),
    radial-gradient(circle at 50% 42%, #dff6ff 0%, #23407a 62%, var(--void-2) 96%);
  background-blend-mode:overlay, normal;
  box-shadow:0 0 60px -12px #6ff0ff, inset 0 -12px 26px -10px #000;
  border-color:#8fe6ff;
}
html[data-skin="skin_magnetar"] .core-label,
.spec-orb[data-skin="skin_magnetar"] .spec-orb-label{color:#eaffff;text-shadow:0 0 8px #6ff0ffcc}

/* ---------- palettes: re-strike all seven epochs at once ----------
   Only the --epoch-N variables change. Everything downstream — nodes,
   connectors, the sheet, the accent, friend pips — follows for free, which is
   the whole reason the palette lives in variables. */
html[data-palette="theme_deepsea"],
.spec-palette[data-palette="theme_deepsea"]{
  --epoch-1:#4ec3c9; --epoch-2:#3aa8c4; --epoch-3:#2f8bbf;
  --epoch-4:#3a6fb0; --epoch-5:#4a55a3; --epoch-6:#5b4b95; --epoch-7:#7ee0d4; --epoch-8:#b9f0ff; --epoch-9:#dbf7ff; --epoch-10:#9fd3e0;
}
html[data-palette="theme_rust"],
.spec-palette[data-palette="theme_rust"]{
  --epoch-1:#d98b4a; --epoch-2:#c46b3a; --epoch-3:#a8502f;
  --epoch-4:#8c4030; --epoch-5:#6f4436; --epoch-6:#8a7355; --epoch-7:#d9c4a0; --epoch-8:#f2e6cf; --epoch-9:#cfc0a6; --epoch-10:#9c8f7a;
}
html[data-palette="theme_neon"],
.spec-palette[data-palette="theme_neon"]{
  --epoch-1:#ff5fa2; --epoch-2:#ff3d8f; --epoch-3:#e030c0;
  --epoch-4:#a040e8; --epoch-5:#5f5cff; --epoch-6:#2fd0ff; --epoch-7:#5fffd0; --epoch-8:#c08cff; --epoch-9:#ff8ae0; --epoch-10:#7a5cff;
}
html[data-palette="theme_mono"],
.spec-palette[data-palette="theme_mono"]{
  --epoch-1:#e8e2ea; --epoch-2:#cfc8d4; --epoch-3:#b4acbd;
  --epoch-4:#9990a6; --epoch-5:#7d748c; --epoch-6:#615a70; --epoch-7:#f4f0f6; --epoch-8:#ffffff; --epoch-9:#c9c3ce; --epoch-10:#948d9b;
}
html[data-palette="theme_solarflare"],
.spec-palette[data-palette="theme_solarflare"]{
  --epoch-1:#fff2b0; --epoch-2:#ffd166; --epoch-3:#ffa733;
  --epoch-4:#ff7420; --epoch-5:#e8451f; --epoch-6:#b52418; --epoch-7:#ffe9a8; --epoch-8:#fffbe8; --epoch-9:#d8cfae; --epoch-10:#a2977a;
}
html[data-palette="theme_bloom"],
.spec-palette[data-palette="theme_bloom"]{
  --epoch-1:#d7e86b; --epoch-2:#a8d858; --epoch-3:#6fc25c;
  --epoch-4:#3fa367; --epoch-5:#2f8272; --epoch-6:#276274; --epoch-7:#e3c766; --epoch-8:#f6f0c0; --epoch-9:#cfd8a8; --epoch-10:#96a377;
}
html[data-palette="theme_ember"],
.spec-palette[data-palette="theme_ember"]{
  --epoch-1:#ffd9a0; --epoch-2:#ffa85e; --epoch-3:#f4713a;
  --epoch-4:#d24a2c; --epoch-5:#a03325; --epoch-6:#6d2320; --epoch-7:#3f1a1c; --epoch-8:#ff8b4a; --epoch-9:#8f4a2c; --epoch-10:#4a2418;
}
/* Season 1, tier 25. Runs the other way to every other palette here: it gets
   PALER as the epochs advance rather than darker, because the season it
   belongs to is the one where the last stars go out. */
html[data-palette="theme_frostline"],
.spec-palette[data-palette="theme_frostline"]{
  --epoch-1:#12304a; --epoch-2:#1d4a6b; --epoch-3:#2f6d8e;
  --epoch-4:#4d92ab; --epoch-5:#7ab6c6; --epoch-6:#a8d3dc; --epoch-7:#d8ecf1; --epoch-8:#8fd0e8; --epoch-9:#bcdfe8; --epoch-10:#eef8fb;
}

/* ---------- shape sets: replace all eight silhouettes ----------
   Same override point the base game uses ([data-epoch]{--shape}), so the
   legend in the guide picks these up without knowing they exist. */
html[data-shapes="shapes_crystal"] [data-epoch="1"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="1"]{--shape:polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)}
html[data-shapes="shapes_crystal"] [data-epoch="2"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="2"]{--shape:polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)}
html[data-shapes="shapes_crystal"] [data-epoch="3"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="3"]{--shape:polygon(50% 0%, 90% 30%, 72% 100%, 28% 100%, 10% 30%)}
html[data-shapes="shapes_crystal"] [data-epoch="4"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="4"]{--shape:polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)}
html[data-shapes="shapes_crystal"] [data-epoch="5"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="5"]{--shape:polygon(20% 0%, 80% 0%, 100% 40%, 50% 100%, 0% 40%)}
html[data-shapes="shapes_crystal"] [data-epoch="6"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="6"]{--shape:polygon(50% 0%, 100% 20%, 85% 80%, 50% 100%, 15% 80%, 0% 20%)}
html[data-shapes="shapes_crystal"] [data-epoch="7"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="7"]{--shape:polygon(50% 0%, 68% 32%, 100% 38%, 76% 64%, 84% 100%, 50% 82%, 16% 100%, 24% 64%, 0% 38%, 32% 32%)}
html[data-shapes="shapes_crystal"] [data-epoch="8"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="8"]{--shape:polygon(50% 0%, 100% 26%, 88% 74%, 50% 58%, 12% 74%, 0% 26%)}
html[data-shapes="shapes_crystal"] [data-epoch="9"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="9"]{--shape:polygon(50% 0%, 84% 18%, 100% 58%, 76% 100%, 24% 100%, 0% 58%, 16% 18%)}
html[data-shapes="shapes_crystal"] [data-epoch="10"],
.spec-shapes[data-shapes="shapes_crystal"] [data-epoch="10"]{--shape:polygon(50% 0%, 100% 30%, 100% 70%, 50% 100%, 0% 70%, 0% 30%)}

html[data-shapes="shapes_organic"] [data-epoch="1"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="1"]{--shape:circle(50%)}
html[data-shapes="shapes_organic"] [data-epoch="2"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="2"]{--shape:ellipse(46% 50%)}
html[data-shapes="shapes_organic"] [data-epoch="3"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="3"]{--shape:inset(0 round 46% 54% 50% 50%)}
html[data-shapes="shapes_organic"] [data-epoch="4"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="4"]{--shape:inset(0 round 54% 46% 52% 48%)}
html[data-shapes="shapes_organic"] [data-epoch="5"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="5"]{--shape:inset(0 round 40% 60% 45% 55%)}
html[data-shapes="shapes_organic"] [data-epoch="6"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="6"]{--shape:inset(0 round 60% 40% 55% 45%)}
html[data-shapes="shapes_organic"] [data-epoch="7"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="7"]{--shape:inset(0 round 50% 50% 38% 62%)}
html[data-shapes="shapes_organic"] [data-epoch="8"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="8"]{--shape:inset(0 round 62% 38% 50% 50%)}
html[data-shapes="shapes_organic"] [data-epoch="9"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="9"]{--shape:polygon(50% 2%, 82% 14%, 98% 46%, 84% 84%, 50% 98%, 16% 84%, 2% 46%, 18% 14%)}
html[data-shapes="shapes_organic"] [data-epoch="10"],
.spec-shapes[data-shapes="shapes_organic"] [data-epoch="10"]{--shape:polygon(50% 4%, 88% 22%, 96% 62%, 66% 96%, 34% 96%, 4% 62%, 12% 22%)}

/* Runic: cut marks rather than solids — every one of these is a shape with at
   least one concave corner, which is what makes them read as carved. */
html[data-shapes="shapes_runic"] [data-epoch="1"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="1"]{--shape:polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%, 25% 50%, 50% 22%, 75% 50%)}
html[data-shapes="shapes_runic"] [data-epoch="2"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="2"]{--shape:polygon(20% 0%, 80% 0%, 80% 34%, 100% 34%, 50% 100%, 0% 34%, 20% 34%)}
html[data-shapes="shapes_runic"] [data-epoch="3"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="3"]{--shape:polygon(50% 0%, 92% 26%, 92% 74%, 50% 100%, 8% 74%, 8% 26%, 50% 50%)}
html[data-shapes="shapes_runic"] [data-epoch="4"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="4"]{--shape:polygon(0% 0%, 100% 0%, 70% 40%, 100% 100%, 0% 100%, 30% 60%)}
html[data-shapes="shapes_runic"] [data-epoch="5"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="5"]{--shape:polygon(50% 0%, 100% 30%, 82% 100%, 50% 70%, 18% 100%, 0% 30%)}
html[data-shapes="shapes_runic"] [data-epoch="6"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="6"]{--shape:polygon(12% 0%, 88% 0%, 100% 50%, 88% 100%, 12% 100%, 0% 50%, 26% 50%)}
html[data-shapes="shapes_runic"] [data-epoch="7"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="7"]{--shape:polygon(50% 0%, 70% 30%, 100% 30%, 76% 56%, 88% 100%, 50% 76%, 12% 100%, 24% 56%, 0% 30%, 30% 30%)}
html[data-shapes="shapes_runic"] [data-epoch="8"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="8"]{--shape:polygon(50% 0%, 66% 34%, 100% 34%, 74% 100%, 50% 56%, 26% 100%, 0% 34%, 34% 34%)}
html[data-shapes="shapes_runic"] [data-epoch="9"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="9"]{--shape:polygon(50% 0%, 62% 30%, 100% 30%, 70% 52%, 82% 100%, 50% 72%, 18% 100%, 30% 52%, 0% 30%, 38% 30%)}
html[data-shapes="shapes_runic"] [data-epoch="10"],
.spec-shapes[data-shapes="shapes_runic"] [data-epoch="10"]{--shape:polygon(50% 0%, 58% 26%, 100% 26%, 66% 48%, 78% 100%, 50% 68%, 22% 100%, 34% 48%, 0% 26%, 42% 26%)}

/* ---------- connectors: the lines between the nodes ----------
   A whole slot for the surface you look at longest. Every rule here overrides
   the same two selectors the base tree uses (.tree-lines path and .lit), and
   each carries a second selector for the shop specimen — which is a REAL
   .tree-lines svg with a REAL .lit path, so a card cannot show a line the tree
   wouldn't draw. */
html[data-connector="wire_filament"] .tree-lines path.lit,
.spec-wire[data-connector="wire_filament"] .tree-lines path.lit{
  stroke-width:1;stroke-dasharray:none;animation:none;opacity:.9;
}
html[data-connector="wire_pulse"] .tree-lines path.lit,
.spec-wire[data-connector="wire_pulse"] .tree-lines path.lit{
  stroke-width:2.6;stroke-dasharray:6 42;stroke-linecap:round;
  animation:connector-flow 1.9s linear infinite;
  filter:drop-shadow(0 0 5px var(--node-color, var(--accent)));
}
html[data-connector="wire_root"] .tree-lines path.lit,
.spec-wire[data-connector="wire_root"] .tree-lines path.lit{
  stroke-width:5;stroke-dasharray:none;stroke-linecap:round;opacity:.55;
  animation:none;
  filter:blur(.6px) drop-shadow(0 0 6px var(--node-color, var(--accent)));
}
/* Rootwork thickens the unlit lines too — the whole tree reads heavier, which
   is the point of it. */
html[data-connector="wire_root"] .tree-lines path,
.spec-wire[data-connector="wire_root"] .tree-lines path{stroke-width:4;opacity:.7}
/* Season 1, tier 40. The only connector with NO animation on the lit path —
   every other one flows. That is the whole idea of it: the current stopped a
   long time ago and the shape it left is still there. */
html[data-connector="wire_lattice"] .tree-lines path.lit,
.spec-wire[data-connector="wire_lattice"] .tree-lines path.lit{
  stroke-width:1.6;stroke-dasharray:2 5;stroke-linecap:butt;opacity:.95;
  animation:none;
  filter:drop-shadow(0 0 4px var(--node-color, var(--accent)));
}
html[data-connector="wire_lattice"] .tree-lines path,
.spec-wire[data-connector="wire_lattice"] .tree-lines path{stroke-width:1.2;opacity:.45}

/* The specimen well. The svg stretches to it, so the curve is the same curve
   at any card size. */
.spec-wire{
  width:100%;height:clamp(30px,9vw,52px);
  display:grid;place-items:stretch;
}
.spec-wire svg{width:100%;height:100%;position:static}
.spec-wire .tree-lines path.lit{--node-color:var(--accent)}

/* ---------- effects ---------- */
/* Dust Trail: the "+N" numbers become drifting motes instead of text. */
html[data-effect~="fx_trail_dust"] .pop{
  font-size:0;width:7px;height:7px;border-radius:50%;
  background:radial-gradient(circle, var(--accent), transparent 70%);
}
/* Ring Ripple: a cleaner, wider, thinner ring on every tap. */
html[data-effect~="fx_ripple_ring"] .ripple{
  border-width:2px;border-color:var(--accent);
  animation-duration:.85s;
}
/* Arc Current: the connectors run brighter and faster. */
html[data-effect~="fx_connector_arc"] .tree-lines path.lit{
  stroke-width:2.4;stroke-dasharray:3 7;
  animation-duration:.6s;
  filter:drop-shadow(0 0 4px var(--node-color, var(--accent)));
}
/* Dense Starfield: a second, offset copy of the star layer. */
html[data-effect~="fx_sky_dense"] .starfield{
  background-size:70% 70%, 55% 55%, 85% 85%, 60% 60%, 75% 75%, 50% 50%, 65% 65%, 90% 90%, 45% 45%;
}

/* ---------- cursor cosmetics ----------
   Each mark is declared ONCE, as a --mark custom property on a selector that
   covers both the worn cursor (on <html>) and its shop preview. The cursor
   rule and the preview rule then both read var(--mark), so a card can never
   show a different arrow from the one you get.

   The dark under-stroke in each path is not decoration: a cursor crosses the
   deep field, a lit node and a white panel in one sweep, and a single-colour
   mark disappears against one of them. */
html[data-cursor="cursor_comet"],   .spec-cursor[data-cursor-preview="cursor_comet"]{
  --mark:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><circle cx="19" cy="19" r="2.6" fill="%237fd8ff" opacity="0.28"/><circle cx="22.5" cy="22.5" r="1.7" fill="%237fd8ff" opacity="0.18"/><path d="M1 1 L1 17 L5.6 12.9 L8.4 19.6 L11.5 18.2 L8.7 11.7 L14.9 11.3 Z" fill="%23120d1a" stroke="%23120d1a" stroke-width="2.6" stroke-linejoin="round"/><path d="M1 1 L1 17 L5.6 12.9 L8.4 19.6 L11.5 18.2 L8.7 11.7 L14.9 11.3 Z" fill="%23dff3ff"/></svg>');
}
html[data-cursor="cursor_probe"],   .spec-cursor[data-cursor-preview="cursor_probe"]{
  --mark:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><path d="M1.5 1.5 L1.5 20 L6 15.5 L14 13 Z" fill="%23120d1a" stroke="%23120d1a" stroke-width="2.4" stroke-linejoin="round"/><path d="M1.5 1.5 L1.5 20 L6 15.5 L14 13 Z" fill="%23a9f0d1"/><path d="M14 13 L23 8" stroke="%23120d1a" stroke-width="3.4" stroke-linecap="round"/><path d="M14 13 L23 8" stroke="%23a9f0d1" stroke-width="1.4" stroke-linecap="round"/><circle cx="23.6" cy="7.6" r="2.2" fill="%23a9f0d1"/></svg>');
}
html[data-cursor="cursor_reticle"], .spec-cursor[data-cursor-preview="cursor_reticle"]{
  --mark:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26"><circle cx="13" cy="13" r="7" fill="none" stroke="%23120d1a" stroke-width="3.4"/><circle cx="13" cy="13" r="7" fill="none" stroke="%23f6e3c0" stroke-width="1.5"/><path d="M13 2.5v4M13 19.5v4M2.5 13h4M19.5 13h4" stroke="%23120d1a" stroke-width="3.2" stroke-linecap="round"/><path d="M13 2.5v4M13 19.5v4M2.5 13h4M19.5 13h4" stroke="%23f6e3c0" stroke-width="1.4" stroke-linecap="round"/><circle cx="13" cy="13" r="1.5" fill="%23f6e3c0"/></svg>');
}
html[data-cursor="cursor_shard"],   .spec-cursor[data-cursor-preview="cursor_shard"]{
  --mark:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><path d="M1 1 L3.5 15.5 L7.5 12 L12 21 L15.5 18.5 L11 10.5 L17 9.5 Z" fill="%23120d1a" stroke="%23120d1a" stroke-width="2.6" stroke-linejoin="round"/><path d="M1 1 L3.5 15.5 L7.5 12 L12 21 L15.5 18.5 L11 10.5 L17 9.5 Z" fill="%23e0c4ff"/><path d="M1 1 L11 10.5 L3.5 15.5 Z" fill="%23ffffff" opacity="0.55"/></svg>');
}
html[data-cursor="cursor_eclipse"], .spec-cursor[data-cursor-preview="cursor_eclipse"]{
  --mark:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><circle cx="14" cy="14" r="9.5" fill="%23ffb35e" opacity="0.35"/><circle cx="14" cy="14" r="7.5" fill="none" stroke="%23ffd08a" stroke-width="2.2"/><circle cx="14" cy="14" r="6.6" fill="%23090610"/><path d="M14 1.5v3M14 23.5v3M1.5 14h3M23.5 14h3" stroke="%23ffd08a" stroke-width="1.6" stroke-linecap="round"/></svg>');
}

/* The worn cursor. Inside the same fine-pointer guard as the default mark —
   a coarse pointer has nothing to draw and must not be charged for one. */
@media (hover:hover) and (pointer:fine){
  html[data-cursor] body{cursor:var(--mark) 1 1, default}
  /* Centred marks put the hotspot in the middle, not at a corner tip. */
  html[data-cursor="cursor_reticle"] body{cursor:var(--mark) 13 13, default}
  html[data-cursor="cursor_eclipse"] body{cursor:var(--mark) 14 14, default}
}

/* A coarse pointer draws no cursor, so these are not merely inert there — they
   must not be ON SALE there. The section is withheld by the same media query
   that decides whether a cursor can be worn at all, which is the only way the
   shop and the game are guaranteed to agree about what a device supports.
   (display:none first, so the default on an unknown device is "don't sell it".) */
.spec-section[data-slot="cursor"]{display:none}
@media (hover:hover) and (pointer:fine){
  .spec-section[data-slot="cursor"]{display:block}
}

/* The shop preview: the same artwork, standing still and enlarged. */
.spec-cursor{
  width:clamp(30px,8vw,46px);aspect-ratio:1;
  background-image:var(--mark);
  background-repeat:no-repeat;background-position:center;background-size:contain;
}

/* ---------- the equip picker ---------- */
.wearing-slot{margin-bottom:16px}
.wearing-slot:last-child{margin-bottom:0}
.wearing-label{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:10px;letter-spacing:.16em;
  text-transform:uppercase;color:var(--muted);margin-bottom:7px;
}
.wearing-options{display:flex;gap:8px;flex-wrap:wrap}
.wearing-option{
  background:none;border:1px solid var(--line);color:var(--muted);
  font:inherit;font-size:12px;padding:8px 13px;border-radius:20px;cursor:pointer;
  transition:border-color .15s ease, color .15s ease, background .15s ease;
}
.wearing-option:hover{border-color:var(--dust);color:var(--dust)}
.wearing-option[aria-pressed="true"]{
  border-color:var(--accent);background:var(--accent-soft);color:var(--accent);
}

/* The title you're wearing, on your own profile. */
.profile-title{
  display:block;font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;font-size:11px;
  letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-top:4px;
}

/* ---------- epoch backdrops ----------
   One real photograph per epoch, behind its band. Public domain NASA imagery —
   see images/epochs/ATTRIBUTION.md for the sources and the three conditions
   that come with it.

   Set as a custom property rather than as a background directly, because the
   element that paints it is .epoch-mural inside the band — sized to the image's
   own aspect ratio so the mask can feather the picture rather than the empty
   margins around it. The opacity there is not optional: at full strength these
   photographs are brighter than any node ring, and the tree would be reading
   second to its own wallpaper. */
.epoch-region[data-epoch="1"]{--epoch-image:url("../images/epochs/01-quantum-foam.jpg");--epoch-aspect:1.155}
.epoch-region[data-epoch="2"]{--epoch-image:url("../images/epochs/02-recombination.jpg");--epoch-aspect:2.0}
.epoch-region[data-epoch="3"]{--epoch-image:url("../images/epochs/03-first-light.jpg");--epoch-aspect:1.727}
.epoch-region[data-epoch="4"]{--epoch-image:url("../images/epochs/04-heavy-elements.jpg");--epoch-aspect:1.361}
.epoch-region[data-epoch="5"]{--epoch-image:url("../images/epochs/05-worlds.jpg");--epoch-aspect:1.778}
.epoch-region[data-epoch="6"]{--epoch-image:url("../images/epochs/06-emergence.jpg");--epoch-aspect:1.0}
.epoch-region[data-epoch="7"]{--epoch-image:url("../images/epochs/07-ascension.jpg");--epoch-aspect:1.386}
.epoch-region[data-epoch="8"]{--epoch-image:url("../images/epochs/08-divergence.jpg");--epoch-aspect:1.133}
.epoch-region[data-epoch="9"]{--epoch-image:url("../images/epochs/09-long-dark.jpg");--epoch-aspect:1.012}
.epoch-region[data-epoch="10"]{--epoch-image:url("../images/epochs/10-evaporation.jpg");--epoch-aspect:1.778}

/* ---------- node photographs ----------
   An owned node with an `image` becomes a round window onto the real object.
   Public domain NASA imagery; see images/nodes/CREDITS.json and
   images/epochs/ATTRIBUTION.md for rights.

   The photograph is a background on the button itself and the SVG glyph stays
   in the DOM underneath it, hidden. That ordering matters: if the image 404s
   or has not downloaded yet, the node is still a legible symbol rather than an
   empty ring, and nothing in the layout moves when it arrives.

   The epoch ring, the owned-count badge and every state colour are untouched —
   they are the node's state and must not start depending on whether a picture
   exists for it. */
/* A photographed node has NO FRAME. The picture is a cut-out — a PNG whose
   alpha came from its own luminance, so the black of space is transparent —
   and it floats, exactly like the reference.
   
   That means switching off the two pseudo-elements that make an ordinary node:
   ::before is the state ring and ::after is the fill, and either one behind a
   free-floating object puts it back in a box. They were what covered the
   photograph entirely in the first version of this.
   
   The state signal survives as LIGHT rather than as a ring: the drop shadow is
   tinted with --state-color, so an affordable node glows the affordable colour
   and an owned one glows owned. That was the risk in dropping the frame, and
   it is the thing to check first if state ever becomes hard to read. */
/* ::after is the interior fill and it must go — it is the plate the picture
   used to sit on. ::before does NOT go: it comes back below as the epoch
   silhouette, held well behind the image as a soft aura.

   That aura is the answer to "why do some nodes have a shape and some have a
   picture". They all have the shape. On a glyph node it is drawn as a ring
   with a filled interior; on a photographed one the same polygon is a diffuse
   glow the picture floats inside. One silhouette language across the whole
   map, and the photograph still reads as a photograph rather than as something
   stuffed into a frame. */
.tree-node.has-photo::after{display:none}
.tree-node.has-photo > svg{opacity:0}

.tree-node.has-photo::before{
  /* A pool of light, not a plate.

     The first attempt kept the epoch polygon here and blurred it. That put a
     flat lavender disc behind every photograph — which is the boxed look the
     photographs were freed from, arriving by the back door. A gradient that
     reaches nothing well inside its own bounds has no edge to read as a
     shape, and it still carries the state colour, which is the only reason
     this element survives at all on a frameless node. */
  inset:-30%;
  clip-path:none;
  background:radial-gradient(closest-side circle,
    color-mix(in srgb, var(--state-color, var(--epoch-color)) 38%, transparent) 0%,
    color-mix(in srgb, var(--state-color, var(--epoch-color)) 12%, transparent) 46%,
    transparent 72%);
  opacity:.75;
  animation:none;   /* the pulse belongs to a ring; on a soft pool it reads as a flicker */
}
/* Affordable is the loudest state in the game and it cannot lose that just
   because a node happens to have a picture. */
.tree-node.has-photo.affordable::before{animation:photo-aura 2.6s ease-in-out infinite}
@keyframes photo-aura{
  0%,100%{opacity:.7}
  50%{opacity:1}
}

.tree-node-photo{
  position:absolute;inset:-6%;z-index:1;pointer-events:none;
  /* background-image is set inline by renderTreeNode(), NOT from a custom
     property here. A relative url() in a custom property resolves against this
     stylesheet, so every node image was requested from /css/images/ and 404'd.
     See the note at the call site. */
  background-size:contain;          /* contain, not cover: a cut-out must not be cropped */
  background-position:center;
  background-repeat:no-repeat;
  /* The guarantee that no node can ever present a straight edge.

     Most of the art is a real cut-out, but not all of it was: eight of these
     shipped as full-frame rectangles with a ten-pixel feather, which is why a
     handful of nodes read as photographs in boxes while their neighbours
     floated. Those have been re-keyed off their own luminance — and this mask
     is the part that keeps it true for art that has not been drawn yet. It
     costs nothing on a cut-out, whose corners are already empty, and it is on
     this element rather than the button so the badges outside the silhouette
     survive it. */
  -webkit-mask-image:radial-gradient(closest-side circle at 50% 50%, #000 56%, #000b 74%, transparent 97%);
  mask-image:radial-gradient(closest-side circle at 50% 50%, #000 56%, #000b 74%, transparent 97%);
}

.tree-node.has-photo{
  filter:
    drop-shadow(0 0 10px color-mix(in srgb, var(--state-color, var(--epoch-color)) 65%, transparent))
    drop-shadow(0 0 26px color-mix(in srgb, var(--epoch-color) 40%, transparent))
    drop-shadow(0 2px 4px #000000cc);
}
/* Affordable is the loudest state in the game and it cannot lose that just
   because a node happens to have a picture. */
.tree-node.has-photo.affordable{
  filter:
    drop-shadow(0 0 14px color-mix(in srgb, var(--state-affordable) 85%, transparent))
    drop-shadow(0 0 34px color-mix(in srgb, var(--state-affordable) 45%, transparent))
    drop-shadow(0 2px 4px #000000cc);
}

/* ---------- the jump menu, shared by both maps ----------
   A custom listbox, replacing a native <select> whose option list drew in the
   operating system's white-on-blue however it was styled. Named for what it
   does rather than where it lives: the timeline has one now too. */
.map-actions{
  position:fixed;z-index:5;
  left:calc(clamp(12px,4vw,18px) + var(--hud-btn) + 14px);
  bottom:calc(env(safe-area-inset-bottom) + 14px);
  display:flex;gap:8px;flex-wrap:wrap;
}
body.firmament-open .map-actions{display:none}
.map-jump{position:relative}
.map-jump-btn{
  display:inline-flex;align-items:center;gap:8px;
  color:var(--text);
}
.map-jump-btn svg{width:10px;height:6px;opacity:.75;transition:transform .15s ease}
.map-jump-btn[aria-expanded="true"] svg{transform:rotate(180deg)}

.map-jump-list{
  position:absolute;bottom:calc(100% + 7px);left:0;z-index:8;
  margin:0;padding:5px;list-style:none;min-width:190px;
  background:var(--void-2);
  border:1px solid var(--line);border-radius:10px;
  box-shadow:0 12px 34px -10px #000000e6;
}
.map-jump-list li{
  padding:8px 11px;border-radius:7px;cursor:pointer;
  font-size:13px;color:var(--text);white-space:nowrap;
}
.map-jump-list li:hover,
.map-jump-list li:focus{
  background:color-mix(in srgb, var(--canvas-key) 22%, transparent);
  color:var(--canvas-key);
  outline:none;
}

/* Where each epoch's photograph actually sits. See .epoch-mural. */
.epoch-region[data-epoch="1"] .epoch-mural{--mx:-26%;--my:-8%;--ms:1.15;--mr:-5deg}
.epoch-region[data-epoch="2"] .epoch-mural{--mx:18%;--my:6%;--ms:1.30;--mr:3deg}
.epoch-region[data-epoch="3"] .epoch-mural{--mx:-30%;--my:10%;--ms:1.10;--mr:6deg}
.epoch-region[data-epoch="4"] .epoch-mural{--mx:26%;--my:-6%;--ms:1.22;--mr:-4deg}
.epoch-region[data-epoch="5"] .epoch-mural{--mx:-14%;--my:12%;--ms:1.34;--mr:5deg}
.epoch-region[data-epoch="6"] .epoch-mural{--mx:30%;--my:4%;--ms:1.05;--mr:-7deg}
.epoch-region[data-epoch="7"] .epoch-mural{--mx:-24%;--my:-9%;--ms:1.28;--mr:4deg}
.epoch-region[data-epoch="8"] .epoch-mural{--mx:16%;--my:11%;--ms:1.12;--mr:-3deg}
.epoch-region[data-epoch="9"] .epoch-mural{--mx:-32%;--my:3%;--ms:1.20;--mr:7deg}
.epoch-region[data-epoch="10"] .epoch-mural{--mx:22%;--my:-7%;--ms:1.26;--mr:-6deg}

/* ---------- how close a node is to being affordable ----------
   A conic gradient swept to --progress, written per frame by updateTreeAfford.
   It sits between the state ring (::before) and the fill (::after), so a full
   arc reads as the ring itself brightening rather than as a separate widget
   bolted to the node.

   Hidden once the node IS affordable: a completed ring on something you can
   already buy is noise, and the affordable state has its own much louder
   signal. */
.tree-node-arc{
  position:absolute;inset:-1px;z-index:2;pointer-events:none;
  clip-path:var(--shape, circle(50%));
  background:conic-gradient(from 180deg,
    color-mix(in srgb, var(--epoch-color) 88%, #fff) calc(var(--progress, 0) * 360deg),
    transparent 0);
  /* An ANNULUS, not a disc. The conic gradient covers the whole node, and
     without this it would paint over the photograph and the glyph — which sit
     at z-index 2 and 2 as well. Masking out everything but the outer few
     pixels turns it into the ring it is meant to be. */
  -webkit-mask-image:radial-gradient(circle, transparent calc(50% - 5px), #000 calc(50% - 3.5px));
  mask-image:radial-gradient(circle, transparent calc(50% - 5px), #000 calc(50% - 3.5px));
  opacity:.95;
  transition:opacity .25s ease;
}
/* Hidden only when the node is affordable — a full ring on something you can
   already buy is noise. NOT hidden on owned: every node here is repeatable, so
   progress toward the NEXT copy is exactly as useful as progress toward the
   first, and on a photographed node — which has no ring of its own, by design —
   the arc is the only state left on the silhouette. */
.tree-node.affordable .tree-node-arc{opacity:0}

/* ---------- milestone pips ----------
   One per doubling at 10/25/50/100. The milestone system is why a cheap early
   node stays worth buying hours in, and it was invisible unless you already
   knew it existed. */
.tree-node-pips{
  position:absolute;left:50%;bottom:-13px;transform:translateX(-50%);
  display:flex;gap:3px;pointer-events:none;z-index:3;
}
.tree-node-pips i{
  width:4px;height:4px;border-radius:50%;
  background:color-mix(in srgb, var(--muted) 55%, transparent);
  box-shadow:0 0 3px #000c;
}
.tree-node-pips i.hit{
  background:var(--epoch-color, var(--dust));
  box-shadow:0 0 5px color-mix(in srgb, var(--epoch-color) 70%, transparent), 0 0 3px #000c;
}

/* ---------- locked nodes, quieter ----------
   They were full-size padlocks scattered across the whole map, which gave the
   road you cannot walk the same visual weight as the one you can. Smaller and
   dimmer: still visible, because seeing what you gave up at a fork is the point
   of having made one, but no longer competing with the frontier. */
.tree-node.locked .tree-node-name{opacity:calc(var(--label-fade, 1) * .75)}

/* ---------- the photograph in the detail sheet ----------
   Full width of the panel, above everything else, because it is the reason to
   open the sheet on a node you have already bought. Bounded in height rather
   than by aspect ratio: the images range from 0.77 to 2.0 and a fixed ratio
   would crop the tall ones to nothing. */
/* No border, no plate, no rounded rectangle. The picture is a cut-out of a
   real object and framing it put the object back in a box — the same thing
   that made the boxed nodes on the map look wrong. What is left is the
   object, floating on the sheet, with a pool of its own light under it. */
.sheet-photo{
  margin:0 0 10px;
  position:relative;
  display:flex;flex-direction:column;align-items:center;
}
.sheet-photo::before{
  /* The light the object sits in. Sized in em of nothing in particular — it is
     atmosphere, and its only job is to stop a dark cut-out reading as a hole
     punched in the panel. */
  content:"";position:absolute;left:50%;top:44%;transform:translate(-50%,-50%);
  width:78%;aspect-ratio:1;border-radius:50%;
  background:radial-gradient(circle, color-mix(in srgb, var(--node-color, var(--accent)) 20%, transparent), transparent 68%);
  pointer-events:none;
}
.sheet-photo img{
  position:relative;
  display:block;margin-inline:auto;
  /* height first, width auto — NOT width:100% with object-fit.
     
     A mask is resolved against the element's box, and with width:100% that box
     was the full panel while the picture sat letterboxed in the middle of it,
     so the mask's edge fell on empty margin and never reached the artwork.
     Sizing the element to the picture puts the two back in the same box. */
  height:min(30vh, 220px);width:auto;max-width:100%;
  transition:opacity .12s linear;
  /* The same guarantee the map's nodes get: whatever the art does at its own
     edge, it leaves this element by fading rather than by stopping. */
  -webkit-mask-image:radial-gradient(closest-side circle at 50% 50%, #000 58%, #000b 78%, transparent 98%);
  mask-image:radial-gradient(closest-side circle at 50% 50%, #000 58%, #000b 78%, transparent 98%);
}
.sheet-photo.loading img{opacity:0}
.sheet-photo figcaption{
  position:relative;
  padding:4px 10px 0;text-align:center;
  font-size:10px;line-height:1.35;color:var(--muted);
  /* Prose, not a figure — deliberately not a tabular-nums rule. */
}

/* ---------- refinements, in the sheet ---------- */
.sheet-refine{margin:14px 0 4px}
.sheet-refine-head{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);
  margin-bottom:7px;
}
.refine-row{
  display:grid;grid-template-columns:1fr auto auto auto;gap:9px;align-items:center;
  padding:7px 10px;margin-bottom:5px;border-radius:8px;
  border:1px solid var(--line);background:var(--void-2);
}
.refine-row.has{border-color:color-mix(in srgb, var(--state-owned) 60%, transparent)}
.refine-row.next{border-color:color-mix(in srgb, var(--node-color, var(--accent)) 55%, transparent)}
.refine-row.locked{opacity:.55}
.refine-name{font-size:12px;font-weight:600;color:var(--text)}
.refine-effect{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:12px;color:var(--node-color, var(--accent));
}
.refine-note{
  font-family:"Space Grotesk",sans-serif;font-variant-numeric:tabular-nums;
  font-size:11px;color:var(--muted);
}
.refine-buy{
  font-size:11px;font-weight:600;padding:4px 11px;border-radius:100px;cursor:pointer;
  border:1px solid var(--node-color, var(--accent));
  background:color-mix(in srgb, var(--node-color, var(--accent)) 18%, transparent);
  color:var(--text);
}
.refine-buy:disabled{opacity:.4;cursor:default}

/* ---------- refinement satellites, on the tree ----------
   One orb per refinement, off the node's shoulder. Positioned on a small arc
   rather than a ring: three dots evenly around a node read as decoration, three
   on one side read as something attached to it. */
.tree-node-sats{
  position:absolute;top:-2px;right:-8px;z-index:3;pointer-events:none;
  display:flex;flex-direction:column;gap:2px;
}
.tree-node-sats i{
  width:7px;height:7px;border-radius:50%;
  background:var(--epoch-color, var(--dust));
  box-shadow:0 0 0 1.5px #000000b3, 0 0 7px color-mix(in srgb, var(--epoch-color) 70%, transparent);
}

/* The Firmament object sheet's orbital portrait. Sized here rather than on the
   canvas element because js/orbits.js reads its own box and scales the system
   to fit — the layout stays CSS's job, the way the tree's does. */
.sky-orbit{
  display: block;
  width: 100%;
  height: 148px;
  margin: 0 0 var(--gap, 12px);
  border-radius: 10px;
  background: radial-gradient(circle at 50% 50%, rgba(255,255,255,.035), transparent 70%);
}
