/* theme.css — design tokens, fonts, reset, and the audio-reactive variables. */

/* Self-hosted fonts (Lighthouse-friendly: no third-party requests). */
@font-face {
  font-family: 'Chakra Petch'; font-style: normal; font-weight: 500;
  font-display: swap; src: url('../fonts/chakra-500.woff2') format('woff2');
}
@font-face {
  font-family: 'Chakra Petch'; font-style: normal; font-weight: 700;
  font-display: swap; src: url('../fonts/chakra-700.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter'; font-style: normal; font-weight: 400;
  font-display: swap; src: url('../fonts/inter-400.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter'; font-style: normal; font-weight: 600;
  font-display: swap; src: url('../fonts/inter-600.woff2') format('woff2');
}

:root {
  /* Palette — overwritten at runtime by theme.js from config.js */
  --c-primary:   #8a2be2;
  --c-secondary: #39ff14;
  --c-accent:    #00e5ff;
  --c-bg:        #070610;
  --c-panel:     #0d0b18;
  --c-text:      #ece9f6;
  --c-muted:     #8b86a8;

  /* Audio-reactive signals (0..1), updated every frame by reactive.js.
     Seven bands + derived signals; --beat snaps to 1 on an onset and decays. */
  --sub: 0; --bass: 0; --lowMid: 0; --mid: 0; --highMid: 0; --treble: 0; --air: 0;
  --level: 0; --beat: 0; --flux: 0; --bright: 0;
  --percussive: 0; --harmonic: 0; --width: 0; --flatness: 0;
  /* Structural signals from structure.js: --build ramps up through a build-up,
     --drop flashes to 1 the instant one lands and decays over ~400ms. */
  --build: 0; --drop: 0;

  --font-display: 'Chakra Petch', system-ui, sans-serif;
  --font-ui: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 16px;
  --dock-h: 60px;
}

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

html, body { height: 100%; }

body {
  font-family: var(--font-ui);
  color: var(--c-text);
  background: var(--c-bg);
  overflow: hidden;                 /* page never scrolls; lists scroll inside */
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

button { font-family: inherit; color: inherit; background: none; border: none; cursor: pointer; }
a { color: var(--c-accent); text-decoration: none; }
img, svg { display: block; }

/* Slim neon scrollbar */
.scroll { scrollbar-width: thin; scrollbar-color: var(--c-primary) transparent; }
.scroll::-webkit-scrollbar { width: 8px; }
.scroll::-webkit-scrollbar-thumb {
  background: linear-gradient(var(--c-primary), var(--c-secondary));
  border-radius: 8px;
}
.scroll::-webkit-scrollbar-track { background: transparent; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

/* app.css — full-screen stage + reactive background layers.
 *
 * Stack, back to front:
 *   0  #bg-visual / #bg-visual-b  Milkdrop source decks (WebGL)
 *   0  #bg-mixer    dual-deck compositor (WebGL)
 *   1  #bg-signature section-aware Milkdrop compositor (2D canvas)
 *   2  #bg-depth    parallax particle field (2D canvas)
 *   3  #glow        ambient color wash
 *   4  #grain       film grain + scanlines, keyed to spectral flatness
 *   5  #vignette    edge falloff + beat flash
 *   6  .stage       the player UI
 */

#bg-visual,
#bg-mixer {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: 0; opacity: 0; transition: opacity 1.2s ease;
  pointer-events: none;
  /* Loudness pushes saturation and contrast, the beat flashes brightness, and
     the low end breathes the whole frame. These ride on `filter`/`transform`
     rather than `opacity` deliberately: opacity carries the 1.2s fade-in
     transition below, so driving it per-frame would both lag the audio by
     over a second and keep a transition permanently in flight. */
  filter:
    saturate(calc(1 + var(--level) * .55 + var(--build) * .35))
    contrast(calc(1 + var(--bass) * .22 + var(--build) * .18))
    brightness(calc(.9 + var(--level) * .14 + var(--beat) * .16 + var(--drop) * .5));
  /* --build creeps the zoom in through a wind-up; --drop kicks it on impact.
     Together they give the picture an anticipation-and-payoff arc that a
     purely frame-by-frame amplitude response can't produce. */
  transform: scale(calc(1.015 + var(--sub) * .035 + var(--build) * .05 + var(--drop) * .04));
  will-change: transform, filter;
}
#bg-visual.on,
#bg-mixer.on { opacity: var(--bg-opacity, .62); }

/* Deck B is always a raw texture source. Once the mixer is healthy it becomes
   the only visible base layer; Deck A stays live underneath as a zero-cost
   fallback if the compositor is disabled or loses its context. */
#bg-visual-b {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: 0; opacity: 0; pointer-events: none;
}
.dual-deck-on #bg-visual { opacity: 0; }
#bg-mixer { pointer-events: none; }

/* The signature layer only appears while a first-time drop assembles and
   lands. Its backing store follows Milkdrop's adaptive render size, so it
   automatically gets cheaper when the quality governor steps down. */
#bg-signature {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: 1; opacity: 0; pointer-events: none;
  will-change: opacity;
}

/* Parallax particle field — perspective depth over the flat Milkdrop plane.
   Reactivity lives in the canvas's own per-particle alpha, so this stays a
   plain fade-in. No mix-blend-mode: the points are already drawn additively
   inside the canvas, and blending a fullscreen layer against its backdrop is
   a real cost on mobile for a difference nobody can see. */
#bg-depth {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: 2; pointer-events: none;
  opacity: 0; transition: opacity 1.6s ease;
}
#bg-depth.on { opacity: 1; }

/* Ambient glow that breathes with the music. */
#glow {
  position: fixed; inset: -20%; z-index: 3; pointer-events: none;
  background:
    radial-gradient(40% 40% at 25% 30%, color-mix(in srgb, var(--c-primary) 60%, transparent), transparent 70%),
    radial-gradient(45% 45% at 78% 70%, color-mix(in srgb, var(--c-secondary) 50%, transparent), transparent 70%),
    radial-gradient(60% 60% at 50% 95%, color-mix(in srgb, var(--c-accent) 35%, transparent), transparent 70%);
  filter: blur(60px) saturate(1.2);
  opacity: calc(.16 + var(--level) * .5 + var(--beat) * .12);
  /* Each corner tracks a different band, so the wash moves internally rather
     than just scaling as one blob. */
  transform:
    scale(calc(1 + var(--bass) * .14))
    translate(calc(var(--mid) * 1.6%), calc(var(--treble) * -1.4%));
  transition: opacity .1s linear, transform .12s linear;
  /* Painted from the SECOND frame, never the first: app.js adds `glow-on` to
     <body> two animation frames after boot. Three viewport-sized gradients
     under a 60px blur are ~230 ms of tile raster without a GPU, and Chrome
     holds the first frame until every tile is done — so in the first frame
     this wash was the whole page's first paint (it doubled it, measured).
     Hidden is not painted, so it costs nothing until it is wanted; it then
     fades in over 1.2 s like #bg-visual. At rest the look is identical. */
  visibility: hidden;
}
body.glow-on #glow { visibility: visible; animation: glow-in 1.2s ease; }
/* `from` only: the `to` is the element's own live opacity (the calc above). */
@keyframes glow-in { from { opacity: 0; } }

/* Film grain + scanlines, driven by spectral flatness — see js/grain.js, which
   publishes every constant below and explains where they came from.
   Deliberately placed over the ambient wash but *under* the vignette: the
   vignette still damps it at the edges, so this stays a background texture and
   never becomes a screen door over the UI text. */
#grain {
  position: fixed; inset: 0; z-index: 4; pointer-events: none;
  display: none;                    /* nothing exists until an analysed track plays */

  /* Noise-vs-tone, remapped out of the narrow band flatness actually occupies
     (corpus median .145), then gated by loudness — digital silence scores a
     perfect 1.0 on flatness, so without the gate every gap in the record would
     grain out. The two `*-gain` values are reciprocals published by grain.js. */
  --grain-drive: clamp(0, calc((var(--flatness) - var(--grain-floor, .08)) * var(--grain-gain, 2.9412)), 1);
  --grain-gate: clamp(0, calc(var(--level) * var(--grain-gate-gain, 8.3333)), 1);
  opacity: calc(var(--grain-drive) * var(--grain-gate));

  /* The one blended fullscreen layer in the stack, and it earns it: overlay
     leaves mid-grey untouched, so the noise modulates contrast instead of
     laying a grey haze over the frame the way source-over would. Blending
     happens once, on the group — the pseudo-elements below composite into it
     normally, because this element's own opacity already isolates them. */
  mix-blend-mode: overlay;
  will-change: opacity;
}
#grain.on { display: block; }

/* Both sub-layers are oversized so their transforms never expose an edge. */
#grain::before,
#grain::after { content: ''; position: absolute; inset: -20%; }

/* The grain: one fractal-noise tile, rasterized once from an inline SVG (no
   request, nothing to cache-bust) and repeated. feColorMatrix averages the
   channels into grey and forces alpha opaque, so the tile centres on overlay's
   neutral 0.5 and only its deviation from grey shows.
   The seethe is `steps(1)`, i.e. discrete jumps between ten offsets rather than
   a smooth glide — grain reshuffles, it does not slide. It is a transform, so
   it stays on the compositor and never repaints the tile. */
#grain::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0.33 0.33 0.33 0 0 0.33 0.33 0.33 0 0 0.33 0.33 0.33 0 0 0 0 0 0 1'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: var(--grain-tile, 160px) var(--grain-tile, 160px);
  opacity: var(--grain-amount, .42);
  animation: grain-seethe var(--grain-seethe, .8s) steps(1) infinite;
  will-change: transform;
}

/* Scanlines roll by exactly one gradient period, so the loop is seamless. */
#grain::after {
  background-image: repeating-linear-gradient(to bottom,
    rgb(0 0 0 / .55) 0 1px, transparent 1px 3px);
  opacity: var(--grain-scan, .18);
  animation: grain-roll 4s linear infinite;
  will-change: transform;
}

@keyframes grain-seethe {
  0%   { transform: translate3d(0, 0, 0); }
  10%  { transform: translate3d(-6%, -4%, 0); }
  20%  { transform: translate3d(-11%, 3%, 0); }
  30%  { transform: translate3d(4%, -8%, 0); }
  40%  { transform: translate3d(-4%, 9%, 0); }
  50%  { transform: translate3d(-12%, -3%, 0); }
  60%  { transform: translate3d(7%, 5%, 0); }
  70%  { transform: translate3d(-9%, -9%, 0); }
  80%  { transform: translate3d(2%, 7%, 0); }
  90%  { transform: translate3d(-7%, -2%, 0); }
}
@keyframes grain-roll { to { transform: translate3d(0, 3px, 0); } }

/* The global reduced-motion rule collapses every animation to .01ms, which for
   an infinite one is meaningless work. Stop these outright and leave a static
   texture — the flatness response itself is not motion and still reads. */
@media (prefers-reduced-motion: reduce) {
  #grain::before, #grain::after { animation: none !important; }
}

/* Edge falloff — sinks the middle of the frame back and keeps UI text legible
   over a busy visualizer. The beat term is a subtle whole-screen flash. */
/* The vignette tightens as a build-up progresses (the frame closes in), then
   snaps open on the drop. That inversion is most of why a drop reads as a
   release rather than just "louder". */
#vignette {
  position: fixed; inset: 0; z-index: 5; pointer-events: none;
  background:
    radial-gradient(calc(120% - var(--build) * 35%) calc(90% - var(--build) * 28%) at 50% 50%,
      transparent 38%,
      color-mix(in srgb, var(--c-bg) 82%, transparent) 100%);
  opacity: calc(.72 - var(--beat) * .22 + var(--build) * .2 - var(--drop) * .45);
  transition: opacity .09s linear;
}

.stage {
  position: relative; z-index: 6;
  height: 100%;
  display: flex; align-items: center; justify-content: center;
  padding: max(16px, env(safe-area-inset-top)) 16px calc(var(--dock-h) + 22px);
}

/* Scrolling track title when it overflows. */
.marquee { display: inline-block; white-space: nowrap; }
.marquee.scrolling { animation: marquee 12s linear infinite; }
@keyframes marquee {
  0%, 8% { transform: translateX(0); }
  92%, 100% { transform: translateX(calc(-100% + 14ch)); }
}

@media (max-width: 540px) {
  .stage { padding: 10px 10px calc(var(--dock-h) + 16px); align-items: stretch; }
}

/* ---------------------------------------------------------------------------
   The music video (video.js, docs/music-video.md V0b).

   The element ships in the markup with no src, so this costs nothing until a
   visitor taps VIDEO. It sits at the back of the stage, in the same plane the
   Milkdrop decks occupy, because it REPLACES them rather than layering over
   them — and the five canvases go out by `visibility`, not `display`, so they
   keep their boxes and coming back costs no reflow (the viewmode.js rule).

   No filter/transform chain of its own: the picture already carries the
   render's own reactivity, and driving CSS filters over decoded video frames
   is the one place on this page where a fullscreen composite is genuinely
   expensive on a phone. */
#trackvideo {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: 0; pointer-events: none;
  object-fit: cover; background: #000;
  opacity: 0; transition: opacity .5s ease;
}
body.video-on #trackvideo { opacity: 1; }
body.video-on #bg-visual,
body.video-on #bg-visual-b,
body.video-on #bg-mixer,
body.video-on #bg-signature,
body.video-on #bg-depth { visibility: hidden; }

/* The glow and the grain stay: they are the room the player sits in, they cost
   nothing extra, and losing them makes the switch read as a different site
   rather than a different picture. The vignette stays for the same reason. */

/* The door. Three things, each one a layout fact rather than a style choice:
   the auto margin is dropped for the same reason .player__orbbtn drops it —
   HUD already carries the one that pushes the group right of the brand, and a
   third would split the free space again and move HUD and ORB; `visibility`
   (not `display`) for the absent case so the bar's width never changes; and a
   min-width because the label swaps VIDEO <-> VISUALS on the tap and a
   narrower word would let the whole group slide. */
.player__videobtn {
  margin-left: 0;
  min-width: 44px; text-align: center;
}
.player__videobtn.is-absent { visibility: hidden; pointer-events: none; }

/* player.css — the Winamp-inspired glass player card. */

.player {
  position: relative; z-index: 2;
  width: min(460px, 100%);
  max-height: 100%;
  display: flex; flex-direction: column;
  background: color-mix(in srgb, var(--c-panel) 78%, transparent);
  backdrop-filter: blur(22px) saturate(1.3);
  -webkit-backdrop-filter: blur(22px) saturate(1.3);
  border: 1px solid color-mix(in srgb, var(--c-primary) 45%, transparent);
  border-radius: var(--radius);
  box-shadow:
    0 24px 70px rgba(0,0,0,.6),
    0 0 calc(26px + var(--level) * 70px) color-mix(in srgb, var(--c-primary) calc(35% + var(--level) * 45%), transparent),
    /* Second ring on the onset only — the card flinches on every hit. */
    0 0 calc(var(--beat) * 46px) color-mix(in srgb, var(--c-accent) calc(var(--beat) * 65%), transparent),
    inset 0 1px 0 rgba(255,255,255,.07);
  transform: scale(calc(1 + var(--beat) * .006));
  overflow: hidden;
  transition: box-shadow .1s linear, border-color .25s ease, transform .09s ease-out;
}

/* Title bar */
.player__top {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 16px;
  background: linear-gradient(180deg, color-mix(in srgb, var(--c-primary) 22%, transparent), transparent);
  border-bottom: 1px solid rgba(255,255,255,.06);
}
/* Three buttons ride the title bar now (HUD, ORB, VIDEO), and the VIDEO one
 * keeps its box while absent so it cannot add a control after first paint.
 * At 380px that leaves the brand too little room for two words, and it wrapped
 * to a second line — taking the whole title bar from 45px to 65px and moving
 * everything below the card with it. A flex item will not shrink past its own
 * content unless it is told it may, so: it may, and the name ellipsises
 * instead of wrapping. */
.player__brand { min-width: 0; }
.player__brand b {
  display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.player__brand b {
  font-family: var(--font-display); font-weight: 700; letter-spacing: .14em;
  font-size: 14px; text-transform: uppercase;
  background: linear-gradient(90deg, var(--c-primary), var(--c-secondary), var(--c-accent));
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.player__eq { display: flex; gap: 3px; align-items: flex-end; height: 18px; }
.player__eq i {
  width: 3px; background: var(--c-secondary); border-radius: 2px;
  height: calc(20% + var(--level) * 80%); transition: height .08s linear;
}
.player__eq i:nth-child(1){ height: calc(12% + var(--sub) * 88%); }
.player__eq i:nth-child(2){ height: calc(12% + var(--bass) * 88%); }
.player__eq i:nth-child(3){ height: calc(12% + var(--mid) * 88%); }
.player__eq i:nth-child(4){ height: calc(12% + var(--highMid) * 88%); }
.player__eq i:nth-child(5){ height: calc(12% + var(--air) * 88%); }

/* "LCD" screen with spectrogram + now-playing */
.player__screen {
  position: relative; padding: 18px 16px 12px; overflow: hidden;
  /* `flex: none` is load-bearing, not tidiness. `.player` is a column flex box
     capped at max-height:100%, and `.player__list` is `flex: 1` — i.e. basis 0%,
     so it absorbs none of the shrink the cap demands. Every other row keeps its
     automatic minimum size, but `overflow: hidden` here resolves this row's
     minimum to zero, so it used to absorb the entire overflow alone: the screen
     collapsed from 87px to ~35px and the title plate spilled onto the seek
     waveform. Only reproduces where the stage centres the card (width > 540px);
     mobile stretches instead, which is why earlier 430px probes missed it. */
  flex: none;
  --track-art: none;
}
.player__screen::before {
  content: ''; position: absolute; inset: -18%; pointer-events: none;
  background-image: var(--track-art);
  background-position: center; background-size: cover;
  filter: blur(2.5px) saturate(1.5) contrast(1.12);
  opacity: 0; transform: scale(calc(1.06 + var(--bass) * .05));
  transition: opacity .8s ease;
}
/* This used to top out around 0.32 behind a 5px blur, which meant the artwork
   was present in the DOM and invisible on screen. The title sits on its own
   opaque plate above (z-index 2, 68% background + backdrop blur), so raising
   this costs no legibility — measured at every viewport in `just verify`. */
.player__screen.has-track-art::before {
  opacity: calc(.42 + var(--harmonic) * .2 + var(--build) * .12);
}
/* A matched video still carries real detail worth resolving; an album cover is
   already a composed image and reads better slightly softer and calmer. */
.player__screen.has-video-art::before { filter: blur(1.5px) saturate(1.55) contrast(1.15); }
#spectrum {
  position: absolute; inset: 0; width: 100%; height: 100%;
  z-index: 1; opacity: .5; pointer-events: none; mix-blend-mode: screen;
}
.player__meta {
  position: relative; z-index: 2; text-align: center; overflow: hidden;
  padding: 6px 14px; border-radius: 12px;
  /* Opaque enough that the spectrum bars behind never wash out the title. */
  background: color-mix(in srgb, var(--c-bg) 68%, transparent);
  backdrop-filter: blur(14px) saturate(1.2);
  -webkit-backdrop-filter: blur(14px) saturate(1.2);
}
.player__title {
  font-family: var(--font-display); font-weight: 700; font-size: 22px;
  line-height: 1.2; color: var(--c-text); white-space: nowrap;
  text-shadow: 0 1px 8px color-mix(in srgb, var(--c-bg) 85%, transparent);
}
/* The box must not depend on the text. `.marquee` is inline-block, so the
   title's box was as wide as its words and re-centred whenever they changed:
   `Select a track` → `Brinstar` moved its start point and Lighthouse scored
   it (#title, CLS 0.013 → 0.014, session 64). A block line with centred
   text keeps the box where it is for any title that fits. A title that does
   not fit scrolls, and only then is the box its text's width — overflowing
   text starts at the left edge either way, so the swap moves nothing. */
.player__title.marquee { display: block; }
.player__title.marquee.scrolling { width: max-content; }
.player__album { margin-top: 4px; font-size: 12px; color: var(--c-muted); letter-spacing: .04em; }
.player__video {
  position: absolute; z-index: 3; right: 7px; bottom: 5px;
  padding: 2px 5px; border-radius: 4px;
  font: 700 8px/1 var(--font-display); letter-spacing: .08em;
  color: color-mix(in srgb, var(--c-text) 75%, var(--c-accent));
  background: color-mix(in srgb, var(--c-bg) 78%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-accent) 35%, transparent);
  opacity: .72; transition: opacity .2s, color .2s, box-shadow .2s;
}
.player__video:hover { opacity: 1; color: var(--c-accent); box-shadow: 0 0 9px var(--c-accent); }

/* Seek */
.player__seek { padding: 6px 16px 0; }
.seek {
  position: relative; height: 8px; border-radius: 6px; cursor: pointer;
  background: rgba(255,255,255,.08); overflow: hidden; --track-art: none;
}
.seek::before {
  content: ''; position: absolute; inset: 0; z-index: 0;
  background-image: var(--track-art); background-size: cover; background-position: center;
  filter: saturate(1.6) contrast(1.15); opacity: 0; transition: opacity .8s ease;
}
.seek.has-track-art::before { opacity: .42; }
.seek__fill {
  position: absolute; z-index: 1; inset: 0 auto 0 0; width: 0%;
  background: linear-gradient(90deg, var(--c-primary), var(--c-secondary));
  box-shadow: 0 0 12px color-mix(in srgb, var(--c-primary) 70%, transparent);
}
/* Waveform mode: only applied when the current track has an analysis artifact
   with peak data. Without one the bar stays the 8px gradient above. */
.seek--wave { height: 34px; border-radius: 8px; background: rgba(255,255,255,.05); }
.seek--wave .seek__fill { display: none; }
.seek__wave { position: absolute; z-index: 1; inset: 0; display: block; width: 100%; height: 100%; }
@media (prefers-reduced-motion: reduce) { .seek--wave { height: 22px; } }
.player__time { display: flex; justify-content: space-between; font-size: 11px; color: var(--c-muted); margin-top: 6px; }

/* Transport */
.player__controls { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 10px 16px 4px; }
.ctl {
  width: 46px; height: 46px; border-radius: 50%;
  display: grid; place-items: center; color: var(--c-text);
  background: color-mix(in srgb, var(--c-primary) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-primary) 20%, transparent);
  transition: transform .15s, background .2s, color .2s, border-color .2s, box-shadow .2s;
}
.ctl:hover {
  background: color-mix(in srgb, var(--c-accent) 22%, transparent);
  border-color: color-mix(in srgb, var(--c-accent) 55%, transparent);
  color: var(--c-accent);
  transform: scale(1.08);
  box-shadow: 0 0 14px color-mix(in srgb, var(--c-accent) 55%, transparent);
}
.ctl svg { width: 22px; height: 22px; }
.ctl--play {
  width: 64px; height: 64px;
  background: linear-gradient(135deg, var(--c-primary), var(--c-accent));
  color: #fff;
  border-color: color-mix(in srgb, var(--c-accent) 60%, transparent);
  box-shadow: 0 0 calc(14px + var(--level)*36px) color-mix(in srgb, var(--c-primary) 70%, transparent);
}
.ctl--play:hover { color: #fff; }
.ctl--play svg { width: 30px; height: 30px; }
.ctl.active { color: var(--c-secondary); border-color: color-mix(in srgb, var(--c-secondary) 50%, transparent); }

/* Volume */
.player__volume { display: flex; align-items: center; gap: 10px; padding: 4px 18px 12px; color: var(--c-muted); }
.player__volume input[type=range] { -webkit-appearance: none; appearance: none; flex: 1; height: 5px; border-radius: 4px;
  background: linear-gradient(90deg, var(--c-primary), var(--c-secondary)); }
.player__volume input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 8px var(--c-primary); cursor: pointer; }
.player__volume input[type=range]::-moz-range-thumb { width: 15px; height: 15px; border: none; border-radius: 50%; background: #fff; cursor: pointer; }

/* The tucked list. The strip is the playlist's collapsed state: `.player`
 * ships with `list-tucked` in the markup (the default layout must be in the
 * first paint — the same CLS trap `has-hud` documents above), and expanding
 * animates only the list row. Every other row stays `flex: none`, so the
 * list remains the only shrinkable row and the screen-collapse trap on
 * `.player__screen` stays dormant. */
.player__listbar {
  flex: none; min-height: 44px;
  display: flex; align-items: center; gap: 10px;
  padding: 0 14px;
  font: inherit; font-size: 12px; letter-spacing: .04em; text-align: left;
  color: var(--c-muted);
  background: transparent; border: none; cursor: pointer;
  border-top: 1px solid color-mix(in srgb, var(--c-primary) 22%, transparent);
  transition: color .2s, background .2s;
}
.player__listbar:hover {
  color: var(--c-text);
  background: color-mix(in srgb, var(--c-primary) 8%, transparent);
}
.player__listbar-text {
  flex: 1; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.player__listbar svg { flex: none; transition: transform .3s ease; }
.player:not(.list-tucked) .player__listbar svg { transform: rotate(180deg); }

/* The desktop Add-tracks strip (web/js/library.js). Inserted by JS only when
 * the shell reports a working importer, so the public site never renders it
 * and the CLS reasoning above is untouched there; inside Electron there is no
 * Lighthouse and the card grows once at boot. */
.player__libadd {
  flex: none; min-height: 34px;
  display: flex; align-items: center; gap: 8px;
  padding: 0 14px;
  font: inherit; font-size: 11px; letter-spacing: .04em; text-align: left;
  color: var(--c-muted);
  background: transparent; border: none; cursor: pointer;
  border-top: 1px solid color-mix(in srgb, var(--c-primary) 14%, transparent);
  transition: color .2s, background .2s;
}
.player__libadd:hover {
  color: var(--c-text);
  background: color-mix(in srgb, var(--c-primary) 8%, transparent);
}
.player__libadd[disabled] { opacity: .6; cursor: default; }
.player__libadd svg { flex: none; }

/* Playlist
 * Everything inside the list — album headers, track numbers, names, subs, the
 * download icon — pulls from the live --c-primary / --c-secondary / --c-accent
 * vars, so the whole list bleeds whatever palette.js is reading off the
 * visualizer. --c-text and --c-muted stay readable; the accents drift. */
.player__list {
  flex: 1; min-height: 80px; overflow-y: auto;
  padding: 6px 8px 14px;
  /* The expand animation rides max-height, not flex-grow, because the card's
     height is driven by the list's *content*: `flex: 1` has basis 0%, which
     against the card's indefinite height resolves to the content size, and
     the card's max-height cap is what shrinks it to fit. Clamping that
     contribution to 0 is what makes the tucked card compact, and animating
     the clamp is what makes the card re-centre smoothly through the stage's
     existing flex centering — no JS measurement anywhere. */
  max-height: 100vh;
  transition: max-height .45s ease, min-height .45s ease, padding .45s ease;
}
.player.list-tucked .player__list {
  max-height: 0; min-height: 0;
  padding-top: 0; padding-bottom: 0;
  overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
  .player__list, .player__listbar svg { transition: none; }
}
@media (max-width: 540px) {
  /* The stage stretches the card at phone widths (app.css). A tucked card
     stretched to full height would pin its rows to the top of an empty glass
     panel, so it centres instead; the expanded card keeps today's full fill. */
  .player.list-tucked { align-self: center; }
}
.album__head {
  display: flex; align-items: center; gap: 8px;
  padding: 12px 8px 6px;
  position: sticky; top: 0; z-index: 1;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--c-panel) 88%, transparent),
    color-mix(in srgb, var(--c-panel) 60%, transparent));
  backdrop-filter: blur(6px);
}
.album__art {
  width: 22px; height: 22px; border-radius: 6px; object-fit: cover; flex: none;
  border: 1px solid color-mix(in srgb, var(--c-primary) 30%, transparent);
}
.album__head h3 {
  font-family: var(--font-display); font-size: 13px; letter-spacing: .08em;
  text-transform: uppercase;
  background: linear-gradient(90deg, var(--c-secondary), var(--c-accent));
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.album__head span {
  font-size: 11px;
  color: color-mix(in srgb, var(--c-muted) 70%, var(--c-primary) 30%);
}

.trk {
  display: flex; align-items: center; gap: 10px; padding: 9px 8px; border-radius: 10px;
  cursor: pointer;
  border: 1px solid transparent;
  transition: background .18s, border-color .18s, box-shadow .18s;
}
.trk:hover {
  background:
    linear-gradient(90deg,
      color-mix(in srgb, var(--c-primary) 14%, transparent),
      color-mix(in srgb, var(--c-accent) 8%, transparent));
  border-color: color-mix(in srgb, var(--c-primary) 28%, transparent);
}
.trk.playing {
  background:
    linear-gradient(90deg,
      color-mix(in srgb, var(--c-primary) 32%, transparent),
      color-mix(in srgb, var(--c-secondary) 18%, transparent));
  border-color: color-mix(in srgb, var(--c-accent) 55%, transparent);
  box-shadow: 0 0 calc(8px + var(--level) * 16px)
              color-mix(in srgb, var(--c-primary) 50%, transparent);
}
.trk__no {
  width: 22px; text-align: center; font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--c-muted) 60%, var(--c-primary) 40%);
  text-shadow: 0 0 calc(var(--level) * 4px) color-mix(in srgb, var(--c-primary) 70%, transparent);
}
.trk.playing .trk__no::before {
  content: '▶';
  color: var(--c-accent);
  text-shadow: 0 0 8px color-mix(in srgb, var(--c-accent) 80%, transparent);
}
.trk.playing .trk__no { font-size: 0; }
.trk__art {
  width: 28px; height: 28px; border-radius: 8px; object-fit: cover; flex: none;
  border: 1px solid color-mix(in srgb, var(--c-primary) 22%, transparent);
  transition: box-shadow .18s;
}
.trk.playing .trk__art {
  box-shadow: 0 0 10px color-mix(in srgb, var(--c-primary) 55%, transparent);
}
.trk__main { flex: 1; min-width: 0; }
.trk__name {
  font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  color: var(--c-text);
  transition: color .2s;
}
.trk:hover .trk__name { color: color-mix(in srgb, var(--c-text) 70%, var(--c-accent) 30%); }
.trk.playing .trk__name {
  background: linear-gradient(90deg, var(--c-text), var(--c-accent));
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.trk__sub {
  font-size: 11px;
  color: color-mix(in srgb, var(--c-muted) 80%, var(--c-secondary) 20%);
}
.trk__dl {
  width: 34px; height: 34px; border-radius: 8px;
  display: grid; place-items: center;
  color: color-mix(in srgb, var(--c-muted) 60%, var(--c-primary) 40%);
  transition: color .2s, background .2s, transform .2s, box-shadow .2s;
}
.trk__dl:hover {
  color: var(--c-accent);
  background: color-mix(in srgb, var(--c-accent) 14%, transparent);
  transform: scale(1.08);
  box-shadow: 0 0 12px color-mix(in srgb, var(--c-accent) 50%, transparent);
}
.trk__dl svg { width: 18px; height: 18px; }

/*
 * track-hud.css — the Track HUD overlay on the player screen.
 *
 * See track-hud.js for why this is an overlay and not a row. Two rules here are
 * correctness rather than taste and are commented where they sit.
 */

/* `hidden` does not hide an element that carries an author `display`, and every
   row and chip below sets one. The admin panel lost an entire render to exactly
   this. Keep this rule above the component. */
[hidden] { display: none !important; }

/* The overlay has no layout height of its own, so the two strips are paid for
   by the screen's existing padding. This class is applied once at boot, before
   first paint, which is why the HUD costs no CLS. */
.player__screen.has-hud { padding-top: 28px; padding-bottom: 26px; }

.thud {
  position: absolute; inset: 0;
  /* Above #spectrum (z-index 1, earlier in the DOM) and below .player__meta
     (z-index 2), so the title plate always wins where the two overlap. */
  z-index: 1;
  pointer-events: none;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: 7px 12px 6px;
  font: 700 9px/1 var(--font-display);
  letter-spacing: .1em; text-transform: uppercase;
  color: color-mix(in srgb, var(--c-text) 62%, transparent);
  text-shadow: 0 1px 7px color-mix(in srgb, var(--c-bg) 92%, transparent);
}

.thud__row { display: flex; align-items: center; gap: 7px; min-width: 0; }
/* The VIDEO chip lives at the screen's bottom-right corner and paints above
   this layer; the key readout ends where it begins. */
.thud__row--bottom { padding-right: 42px; }

.thud__section {
  color: var(--c-accent);
  max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 6.9% of analysed segments have no label. Saying so is the honest render. */
.thud__section.is-unlabelled { color: var(--c-muted); font-style: italic; }

.thud__bar, .thud__conf { color: var(--c-muted); }

.thud__next {
  margin-left: auto; white-space: nowrap;
  color: color-mix(in srgb, var(--c-primary) 70%, var(--c-text));
}
.thud__next::before { content: '→ '; opacity: .7; }

.thud__tempo { color: var(--c-text); white-space: nowrap; }

/* Which estimator is actually driving. `live` is not a failure — it is the
   answer on the seven tracks whose offline grid the analyser itself distrusts,
   and hiding it would make the tempo look more certain than it is. */
.thud__source {
  padding: 2px 4px; border-radius: 3px; font-size: 8px;
  color: color-mix(in srgb, var(--c-text) 70%, transparent);
  background: color-mix(in srgb, var(--c-bg) 55%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-text) 18%, transparent);
  white-space: nowrap;
}
.thud.is-live .thud__source {
  color: var(--c-accent);
  border-color: color-mix(in srgb, var(--c-accent) 45%, transparent);
}

.thud__key {
  margin-left: auto; white-space: nowrap;
  color: color-mix(in srgb, var(--c-secondary) 75%, var(--c-text));
}
/* A weak key is a measurement, not a mistake: strength bottoms out at 0.557 on
   the 19-minute mix, where one key label for the whole file is simply wrong.
   The tilde says "about" without needing a legend. */
.thud.is-weak .thud__conf { opacity: .8; }
.thud.is-weak .thud__conf::before { content: '~'; }

/* Same idea one row up: on the seven tracks whose grid the analyser marked
   unstable, the bar count is a guess and is drawn as one. */
.thud.is-loose .thud__bar { opacity: .7; }
.thud.is-loose .thud__bar::before { content: '~'; }

/* The tuner needle. The named note (the artifact's key, to the left) is the
   marked pitch; the needle is where the last few seconds of live audio point,
   deflected by distance around the Camelot ring. Its position and opacity are
   written at the HUD's 9Hz — the transitions carry the sweep in between, so
   it moves like a meter rather than stepping like a readout. */
.thud__needle {
  position: relative; flex: none; align-self: center;
  width: 36px; height: 9px;
  transition: opacity .25s ease;
}
.thud__needle::before {  /* the scale line */
  content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1px;
  background: color-mix(in srgb, var(--c-text) 20%, transparent);
}
.thud__needle::after {   /* centre tick: "in tune" */
  content: ''; position: absolute; left: 50%; top: 1px; bottom: 1px; width: 1px;
  margin-left: -0.5px;
  background: color-mix(in srgb, var(--c-text) 38%, transparent);
}
.thud__needle i {
  position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px;
  background: color-mix(in srgb, var(--c-primary) 85%, var(--c-text));
  /* Deflection is a unitless -1..1 written by the HUD; the travel is CSS so a
     narrower track (the phone query below) shortens the throw automatically. */
  transform: translateX(calc(var(--defl, 0) * 16px));
  transition: transform .3s ease, background-color .3s ease, box-shadow .3s ease;
}
.thud.is-tuned .thud__needle i {
  background: var(--c-accent);
  box-shadow: 0 0 5px color-mix(in srgb, var(--c-accent) 70%, transparent);
}

/* The live estimate's own name, spelled out only where it disagrees with the
   named key — centred-and-agreeing would just repeat the label to its left.
   Disagreement is information, not error: on a mix it is the signal that the
   music moved before the label did. */
.thud__live { color: var(--c-muted); white-space: nowrap; }
.thud__live::before { content: '\2260 '; opacity: .7; }

/* Beat pips off the same grid every visual layer schedules against. */
.thud__pips { display: flex; gap: 3px; align-items: center; }
.thud__pips i {
  width: 4px; height: 4px; border-radius: 50%;
  background: color-mix(in srgb, var(--c-text) 22%, transparent);
  transition: background-color .12s ease, box-shadow .12s ease;
}
.thud__pips i.is-on { background: color-mix(in srgb, var(--c-primary) 80%, transparent); }
.thud__pips i.is-downbeat {
  background: var(--c-accent);
  box-shadow: 0 0 6px color-mix(in srgb, var(--c-accent) 70%, transparent);
}

/* The build ramp. Deliberately not on the HUD's 9Hz text path: --build is
   already published on :root every frame by reactive.js and zeroed there on
   pause, so a transform over it updates at frame rate for free and reaches
   exact zero without a reset path of its own. */
.thud__build {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  transform: scaleX(var(--build, 0)); transform-origin: left center;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--c-secondary) 80%, transparent), var(--c-accent));
  opacity: calc(.35 + var(--build, 0) * .65);
}

/* The toggle, in the title bar rather than on the screen: the screen is where
   the HUD draws, and a control that sits inside what it hides is a control
   nobody can find once it is off. */
.player__hudbtn {
  margin-left: auto; margin-right: 10px;
  padding: 3px 6px; border-radius: 4px; cursor: pointer;
  font: 700 8px/1 var(--font-display); letter-spacing: .12em;
  color: color-mix(in srgb, var(--c-text) 78%, var(--c-accent));
  background: color-mix(in srgb, var(--c-bg) 55%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-accent) 35%, transparent);
  transition: opacity .2s, color .2s, box-shadow .2s, border-color .2s;
}
.player__hudbtn:hover { color: var(--c-accent); box-shadow: 0 0 9px color-mix(in srgb, var(--c-accent) 45%, transparent); }
.player__hudbtn.is-off {
  color: var(--c-muted); opacity: .7;
  border-color: color-mix(in srgb, var(--c-text) 16%, transparent);
}

/* Phones: the strips stay, the section label gives up width first. The live
   key name goes next — the needle still carries the disagreement, just not
   the spelling — and the needle track narrows. */
@media (max-width: 430px) {
  .thud { font-size: 8px; letter-spacing: .08em; padding: 6px 10px 5px; }
  .thud__row { gap: 5px; }
  .thud__section { max-width: 34%; }
  .thud__live { display: none; }
  .thud__needle { width: 28px; }
  .thud__needle i { transform: translateX(calc(var(--defl, 0) * 12px)); }
}

/* viewmode.css — immersive and orb modes: the stage and dock fade out and
 * release the pointer, but keep their layout boxes, so returning costs no
 * reflow and no CLS. The background stack (mixer, decks, depth, glow, grain,
 * vignette) is untouched — hiding everything except the visuals is the whole
 * feature. Orb adds a minimal circular player on top of the same hide. */

.stage, .dock {
  visibility: visible;
  transition: opacity .6s ease, visibility 0s;
}
body.mode-immersive .stage,
body.mode-immersive .dock,
body.mode-orb .stage,
body.mode-orb .dock {
  opacity: 0; visibility: hidden;
  /* Immediately, not after the fade: a mid-fade click must not land on a
     control that is disappearing. */
  pointer-events: none;
  transition: opacity .6s ease, visibility 0s linear .6s;
}

/* Boot restore of a persisted orb: applied for two frames so entering the
 * stored mode lands as the first paint, not as a fade the visitor watches. */
body.mode-instant .stage,
body.mode-instant .dock,
body.mode-instant .orb {
  transition: none;
}

/* --- output mode (?output=1) ---------------------------------------------
 * The clean frames-out surface (output.js, docs/desktop.md D3a): background
 * visual stack only, for capture — the desktop shell's borderless output
 * window, an OBS Browser Source, the D3b OSR window. Instant, no fade: the
 * page boots hidden and stays hidden; a capture must never catch the UI
 * mid-transition. The chrome modules are skipped at boot, so the toast, orb
 * and Lab rules below are belt over suspenders for anything a future module
 * might still create. */
body.mode-output { cursor: none; }
body.mode-output .stage,
body.mode-output .dock {
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: none;
}
body.mode-output .mode-toast,
body.mode-output .orb,
body.mode-output #visual-hud { display: none !important; }

/* The mode toast: names the mode and the way back on every entry, and
 * carries the one-time discovery hint. Above the Lab (z 1000) on purpose —
 * it is the only thing allowed to speak over a hidden page. */
.mode-toast {
  position: fixed; left: 50%; bottom: calc(var(--dock-h, 60px) + 34px);
  transform: translateX(-50%) translateY(8px);
  padding: 10px 18px; border-radius: 999px;
  font: 600 12px/1 var(--font-display); letter-spacing: .08em;
  color: var(--c-text); white-space: nowrap;
  background: color-mix(in srgb, var(--c-panel) 88%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-primary) 45%, transparent);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 10px 30px rgba(0,0,0,.5);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .3s ease, transform .3s ease, visibility 0s linear .3s;
  z-index: 1200;
}
.mode-toast.show {
  opacity: 1; visibility: visible;
  transform: translateX(-50%) translateY(0);
  transition: opacity .3s ease, transform .3s ease, visibility 0s;
}

/* The door into orb mode, in the title bar beside HUD — the same
 * .player__hudbtn pattern, so it inherits the size, the border and the hover.
 * Only the auto margin is dropped: HUD already carries the one that pushes
 * the pair to the right of the brand, and a second auto margin would split
 * the free space between them. It ships in the markup (index.html), so the
 * title bar never gains a control after first paint. */
.player__orbbtn { margin-left: 0; }

/* --- the orb -------------------------------------------------------------
 * A single fixed circular control, present only in orb mode. It rides the
 * CSS vars reactive.js already publishes (--level, --beat, --build), so it
 * moves with the deck canvases behind it and zeroes exactly on pause with
 * zero JS on the frame path. */
.orb {
  position: fixed; left: 50%; bottom: calc(var(--dock-h, 60px) + 90px);
  transform: translateX(-50%);
  width: 72px; height: 72px; border-radius: 50%;
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .6s ease, visibility 0s linear .6s;
  z-index: 900;
}
body.mode-orb .orb {
  opacity: 1; visibility: visible; pointer-events: auto;
  transition: opacity .6s ease, visibility 0s;
}

/* The reactive ring. Loudness breathes the outer glow, a beat snaps a
 * tighter accent halo and a ~5% scale pop, and the build arc sweeps a thin
 * annulus clockwise from 12 o'clock as the track winds up. */
.orb__ring {
  position: absolute; inset: -7px; border-radius: 50%;
  box-shadow:
    0 0 calc(10px + var(--level, 0) * 44px)
      color-mix(in srgb, var(--c-primary) calc(30% + var(--level, 0) * 50%), transparent),
    0 0 calc(var(--beat, 0) * 30px)
      color-mix(in srgb, var(--c-accent) calc(var(--beat, 0) * 60%), transparent);
  transform: scale(calc(1 + var(--beat, 0) * .05));
  background: conic-gradient(from -90deg,
    color-mix(in srgb, var(--c-accent) 85%, transparent) calc(var(--build, 0) * 1turn),
    color-mix(in srgb, var(--c-primary) 22%, transparent) 0);
  -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
  mask: radial-gradient(closest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
}

/* The artwork disc — the vinyl idiom: it turns while music plays
 * (viewmode.js toggles .playing off the audio element's own events). */
@keyframes orb-spin { to { transform: rotate(1turn); } }
.orb__art {
  position: absolute; inset: 0; width: 100%; height: 100%;
  border-radius: 50%; object-fit: cover;
  animation: orb-spin 12s linear infinite;
  animation-play-state: paused;
  box-shadow: 0 6px 24px rgba(0,0,0,.55);
}
.orb.playing .orb__art { animation-play-state: running; }

/* Transport, revealed on hover or focus. The buttons stay clickable while
 * invisible — the centre of the orb is always play/pause, which is the
 * "clicking the orb toggles play" behaviour, and a first touch on mobile
 * both reveals and (on the centre) acts. */
.orb__controls {
  position: absolute; inset: 0; border-radius: 50%;
  display: flex; align-items: center; justify-content: center; gap: 2px;
  background: rgba(5, 4, 12, .48);
  opacity: 0; transition: opacity .2s ease;
}
.orb:hover .orb__controls,
.orb:focus-within .orb__controls { opacity: 1; }
.orb__btn {
  background: none; border: 0; padding: 0; cursor: pointer;
  width: 18px; height: 22px; color: var(--c-text);
  display: grid; place-items: center;
}
.orb__btn svg { width: 14px; height: 14px; }
.orb__btn--play { width: 24px; }
.orb__btn--play svg { width: 18px; height: 18px; }

/* One line, centred under the disc; ellipsis is what survives 380px. Also the
 * way out of the mode, and the ONLY one a visitor with no keyboard has — so it
 * is a real <button> and it is padded to a thumb-sized target rather than the
 * ~15px a line of 11px type would give. The padding is what the `top` offset
 * pays back, so the text sits exactly where it did before. */
.orb__label {
  position: absolute; top: calc(100% + 2px); left: 50%;
  transform: translateX(-50%);
  max-width: min(320px, 86vw);
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  font: 600 11px/1.4 var(--font-display); letter-spacing: .1em;
  text-align: center; color: var(--c-text); opacity: .85;
  text-shadow: 0 1px 8px rgba(0,0,0,.8);
  -webkit-appearance: none; appearance: none;
  background: none; border: 0; padding: 10px 14px; cursor: pointer;
  transition: opacity .2s ease;
}
.orb__label:hover, .orb__label:focus-visible { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .stage, .dock, .mode-toast, .orb, .orb__controls, .orb__label { transition: none; }
  .orb__art { animation: none; }
}

/* --- console mode (?console=1) --------------------------------------------
 * The second-monitor command surface (console-link.js, docs/console.md). The
 * player markup parses but must never flash while /assets/console.css is
 * still in flight, so the hiding lives here in the public bundle — the same
 * reasoning as mode-output above, minus the cursor rule: a console is a
 * pointing surface. */
body.mode-console .stage,
body.mode-console .dock {
  display: none !important;
}
