:root {
  --bg: #0a0a0a;
  --fg: #f5f5f5;
  --muted: #9a9a9a;
  --line: #222222;

  /* Fog macros — the only dials you need to touch to retune the whole
     ambient fog effect. Everything below reads from these. */
  --fog-intensity: 0.5; /* multiplies every opacity keyframe value below (1 = current, 0.5 = half as visible, 1.5 = 50% more) */
  --fog-brightness: 1; /* 0-1, dims the fog texture itself toward gray before it's blended in (1 = the PNG's native near-white, lower = darker gray) */
  --fog-speed: 0.7; /* multiplies scroll speed (1 = current, 2 = twice as fast/half the duration, 0.5 = half as fast/twice the duration) */
  --fog-blur: 0px; /* softens the fog texture's own detail — smaller = crisper/more visible PNG grain, larger = smoother/softer wisps */

  /* Scene grade macros — a master exposure/contrast pass over the disks
     + rays + fog as a group (see .scene-grade below), plus size/strength
     for the whole-page film grain. These grade already-composited
     pixels, not any one element's own color — see .scene-grade's
     comment for why that matters. */
  --scene-exposure: 1.3; /* brightness multiplier over the disks/rays/fog only (not the vignette or grain, which paint above this and stay untouched) — 1 = unchanged, no ceiling: crank as high as needed to blow highlights to clipped white */
  --scene-contrast: 1.2; /* contrast multiplier over the same group — 1 = unchanged, >1 = punchier (brighter brights, darker darks), <1 = flatter. Kept modest deliberately: contrast is what turns subtle 8-bit gradient steps into visible hard-edged banding, especially after dimming (--fog-brightness) and re-brightening (--scene-exposure) compress the fog's tonal range down and then stretch it back out — pushing this much higher re-introduces that. --fog-brightness is left at 1 (no dimming at all) for the same reason, to avoid that compress/stretch round-trip in the first place. */

  /* --grain-image and --grain-size are set at runtime by js/grain.js,
     not here — GRAIN_SIZE and GRAIN_CONTRAST in that file are the real
     macros. A CSS custom property's value can't be read into a data:
     URI string, so the noise SVG (and its baseFrequency, which is what
     actually controls speckle fineness — background-size alone cannot)
     has to be generated in JS instead. These are just fallbacks for the
     instant before that script runs. */
  --grain-image: none;
  --grain-size: 200px;
  --grain-intensity: 1; /* strength dial for the grain layer (see body::after) — mix-blend-mode: overlay caps its own visible effect at roughly 2x the backdrop's brightness, which was tried against and reverted from screen: overlay's weakness in the darkest areas is deliberately preferred here, since it makes grain visibility scale with the underlying scene's own local brightness (near-invisible over pure black, strongest over the bright disks/rays) rather than adding a uniform amount everywhere — closer to how real film grain behaves. What was actually too weak was the noise pattern's own sharpness, not the blend mode — see GRAIN_* macros in js/grain.js. */
  --grain-speed: 5; /* multiplies grain drift speed (1 = current, 2 = twice as fast/half the duration, 0.5 = half as fast/twice the duration) — same calc(base / speed) convention as --fog-speed */

  /* --dither-image/--dither-cell/--dither-opacity are set at runtime by
     js/dither.js, not here — DITHER_MATRIX_SIZE, DITHER_CELL_PX, and
     DITHER_OPACITY there are the real macros. Same reason as grain: a
     CSS custom property's value can't be read into a data: URI string,
     and the matrix size specifically can't be a plain CSS value at all
     (it changes how many rects the generated SVG needs). These are just
     fallbacks for the instant before that script runs. A CSS-only
     ordered (Bayer) dither, adapted from github.com/flornkm/dither-
     plugin, screen-blended on top of everything (see .dither-overlay
     below) — the safe half of that plugin's technique only; see
     .dither-overlay's comment for why the other half (wrapping content
     in a heavy grayscale/contrast filter to crush it first) isn't used
     here. */
  --dither-image: none;
  --dither-cell: 16px;
  --dither-opacity: 0.5;

  /* Per-disk vignette — a true opacity fade on each disk's own image
     (not the rays, not the whole page; that's --master-vignette-*
     below). A mix-blend-mode: multiply overlay was tried and reverted:
     it darkens whatever's BEHIND it too, fog included, which isn't
     "opacity" at all — it's a filter over the fog, not a fade of the
     disk. A real mask-image only affects this element's own alpha, so
     the fog behind a faded disk stays exactly as bright as it already
     was. Simple 100%-sized mask on .disk img directly — the glow that
     used to force an oversized/rescaled mask here now lives on the
     separate, unmasked .disk-glow, so this element has nothing bleeding
     past its own box to accidentally clip anymore. */
  --disk-vignette-inner: 75%; /* where the fade begins — everything inside stays fully opaque */
  --disk-vignette-outer: 100%; /* where it reaches fully transparent — 100% = right at the disk's own edge */

  /* Bloom feather — fades .disk-glow's own edge (all four drop-shadow
     layers, though near/far/halo are tight enough to stay within the
     default "inner" radius untouched; bloom, being the one that
     reaches far, is what this actually visibly tapers) independent of
     BLOOM_GLOW_PX (blur radius) and BLOOM_GLOW_ALPHA (opacity) in
     js/disk.js — those control softness and strength, but couple
     "how far it reaches" to "how soft it is"; this decouples them. See
     .disk-glow's comment for the physically-enlarged-box technique that
     keeps this from repeating the earlier disk-vignette clipping bugs.
     Same 0-100 = relative-to-the-disk's-own-radius scale as
     --disk-vignette-*. */
  --bloom-feather-inner: 40%; /* where the bloom's own fade begins */
  --bloom-feather-outer: 220%; /* where it reaches fully transparent — well past the disk's own edge (100%), since the bloom is SUPPOSED to extend past the disk; this just gives it a controllable end point instead of trailing off indefinitely */

  /* Master (whole-page) vignette — the existing frame around the entire
     viewport (see body::before), pulling the edges toward black so any
     light source (the disks) reads as piercing through a dark void.
     Was a hardcoded radial-gradient; these are the same two stops (plus
     strength) as real, adjustable macros. */
  --master-vignette-inner: 20%; /* radius (as % of the viewport, ellipse-shaped) that stays fully untouched before the darkening begins */
  --master-vignette-outer: 100%; /* radius where the darkening reaches its full strength (see --master-vignette-strength) */
  --master-vignette-strength: 0.8; /* 0-1, how dark the fully-darkened edge gets — 1 = pure black, lower = a dimmer/more subtle frame */

  /* Center disk's hub dot — how big a circle, centered on the dot,
     reacts to hover (see .disk-dot-hover-target below). Tune this live
     on dev/tune.html. */
  --dot-hover-diameter: 20px;
  /* Scales --dot-spin-blur (js/disk.js's render(), same blur radius the
     spinning disk's own image gets) before it reaches the dot's filter
     below — 1 = exact match to the disk's blur, 0 = none, >1 =
     exaggerated. Tune this live on dev/tune.html. */
  --dot-spin-blur-mult: 2.35;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--fg);
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 400;
}

/* Whole-site ("master") vignette: pulls the edges toward black so the
   page reads as a dark void that any light source (the disks) pierces
   through — see --master-vignette-* in :root above for the adjustable
   inner/outer stops and strength. Text elements (header/footer/page
   copy) are bumped to z-index: 1001 so they render above this instead
   of getting darkened by it, since the header/footer corners are
   exactly where the vignette is strongest. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 1000;
  pointer-events: none;
  background: radial-gradient(ellipse at center, transparent var(--master-vignette-inner), rgba(0, 0, 0, var(--master-vignette-strength)) var(--master-vignette-outer));
}

/* Master exposure/contrast grade over the disks + rays + fog as a
   group — nothing else. backdrop-filter grades whatever is already
   painted *behind* this element (z-index 4, above disk-stage's own 2),
   which means it operates on the final composited pixel color, not any
   single element's own (possibly near-transparent) color. That
   distinction is why this works as a real, no-ceiling brightness/
   contrast control where filter: brightness() on the rays themselves
   could not: a soft, low-alpha white ray blended faintly over black
   composites to a dim gray pixel, and no amount of brightening the
   ray's own (already-white) RGB before compositing changes that — but
   grading the dim gray pixel itself afterward can push it all the way
   to clipped white. The vignette and grain paint above this (z-index
   1000 and 6) and are deliberately left ungraded, so they stay a stable
   frame/texture instead of blowing out with everything else.
   (This only actually works now that .disk-stage.page-stage has its own
   z-index: 2 override below it — it used to inherit .page-stage's 1001
   and paint ABOVE this layer, meaning there was nothing behind this
   backdrop-filter to grade at all.) */
.scene-grade {
  position: fixed;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  backdrop-filter: brightness(var(--scene-exposure)) contrast(var(--scene-contrast));
  -webkit-backdrop-filter: brightness(var(--scene-exposure)) contrast(var(--scene-contrast));
}

/* Ordered (Bayer) dither pattern, screen-blended over the disks + rays +
   fog + scene-grade (z-index 5, above .scene-grade's 4), sitting below
   the film grain (z-index 6) — a structured, geometric texture next to
   grain's random one, per github.com/flornkm/dither-plugin (see
   --dither-image's comment for the pattern itself).

   That plugin's full technique also wraps the target content in a heavy
   grayscale/contrast filter to crush it toward black/white first, so
   the dither pattern reads as real quantization rather than a texture
   sitting on top. That half is deliberately NOT used here: filter on an
   element makes it the containing block for any position: fixed
   descendant, and .smoke is position: fixed relying on the real
   viewport for its inset: 0 — wrapping .disk-stage that way would
   collapse the whole full-viewport fog down to disk-stage's own small
   bounding box. It would also isolate .disk-rays'/.smoke-layer's
   mix-blend-mode: screen from the true page backdrop, the same class of
   bug fixed twice already in this file (see .disk-rays and .smoke-layer
   comments). This overlay avoids both: it doesn't wrap or filter
   anything, just adds its own pattern on top, the same safe shape as
   the grain layer below. */
.dither-overlay {
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  background-image: var(--dither-image);
  background-repeat: repeat;
  background-size: var(--dither-cell) var(--dither-cell);
  image-rendering: pixelated;
  opacity: var(--dither-opacity);
  mix-blend-mode: screen;
}

/* Film-grain noise over the whole page — above the disks/fog/scene-grade
   /dither/vignette, but below actual text (nav, footer, page headings):
   see the z-index bump on those elements below. The pattern itself is
   generated by js/grain.js (GRAIN_SIZE / GRAIN_CONTRAST there are the
   real macros) rather than hardcoded here — see --grain-image's
   comment above for why. mix-blend-mode: overlay is a deliberate choice,
   not an oversight — see --grain-intensity's comment above for why.
   Drifts via background-position, exactly one tile per loop (0% to 100%
   of the tile's own size, same technique as .disk-rays' mask-position
   drift below) — a repeating pattern moved by exactly its own tile size
   loops with no visible seam or jump. */
body::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 6;
  pointer-events: none;
  opacity: var(--grain-intensity);
  mix-blend-mode: overlay;
  background-image: var(--grain-image);
  background-repeat: repeat;
  background-size: var(--grain-size) var(--grain-size);
  animation: grain-drift calc(10s / var(--grain-speed)) linear infinite;
}

@keyframes grain-drift {
  from {
    background-position: 0% 0%;
  }
  to {
    background-position: 100% 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  body::after {
    animation: none;
  }
}

.site-header {
  position: relative;
  z-index: 1001;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1.5rem 2rem;
}

.site-logo-link {
  display: block;
  line-height: 0;
}

.site-logo {
  display: block;
  height: 66px;
  width: auto;
}

.site-nav a {
  position: relative;
  top: -1rem;
  color: var(--muted);
  text-decoration: none;
  margin-left: 1.5rem;
  font-size: 0.6732rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

.site-nav a:hover,
.site-nav a[aria-current="page"] {
  color: var(--fg);
}

main {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  padding: 2rem;
  text-align: center;
}

/* Shows and Contact only — align-items: center above still keeps the
   whole text block centered as a box on the page; this just left-
   aligns the actual text/lines inside that box, rather than each line
   individually centering under the one above it. */
main.text-left {
  text-align: left;
}

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

.site-footer {
  position: relative;
  z-index: 1001;
  text-align: center;
  padding: 1.5rem;
  color: var(--muted);
  font-size: 0.576rem;
}

.page-title {
  position: relative;
  z-index: 1001;
  font-size: 1rem;
  font-weight: 400;
}

.empty-state {
  position: relative;
  z-index: 1001;
  color: var(--muted);
}

/* Shared quick-fade treatment for a page's main content block, driven
   by js/page-transition.js: fades in on arrival (no JS needed — plays
   automatically whenever the page loads, direct visit or navigation)
   and fades out (via .page-stage-leaving) right before the script
   navigates to another page. Any page's main content wrapper can opt
   in by adding this class alongside its own — currently Home's
   .disk-stage and Contact's main wrapper. */
.page-stage {
  /* opacity < 1 (mid-animation) forms its own stacking context, which
     would otherwise trap its children's z-index: 1001 below the
     vignette/noise for the transition's duration — matching that
     z-index here keeps the whole group correctly on top throughout.
     This assumes the page-stage's own content IS text that wants to
     sit above the vignette/grain (true for Contact's title/links,
     which carry z-index: 1001 themselves) — see .disk-stage.page-stage
     below for the one case where that assumption is wrong. */
  position: relative;
  z-index: 1001;
  transition: opacity 0.15s ease;
  animation: page-stage-in 0.15s ease;
}

@keyframes page-stage-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.page-stage-leaving {
  opacity: 0;
}

/* Home page disks */
.disk-stage {
  position: relative;
}

/* .page-stage's shared z-index: 1001 exists for TEXT content (Contact's
   title/links, which already carry their own z-index: 1001) that needs
   to stay above the vignette/scene-grade/grain during the fade
   transition. Home's disk-stage reuses .page-stage only for that fade
   behavior (opacity/transition/animation), not for that z-index — its
   own children (.disk-row at 1, .smoke at 3) were deliberately kept
   low, on the assumption that the whole group sits BELOW the vignette/
   scene-grade/grain, the way every comment on those layers describes.
   Without this override it doesn't: disk-stage inherits 1001 from
   .page-stage and paints above all of them, which is why .scene-grade's
   backdrop-filter (grading whatever paints *behind* it) had nothing of
   the disks/rays/fog to actually grade. This restores the intended
   order: disk-stage (2) < .scene-grade (4) < grain (6) < vignette
   (1000) < text (1001). */
.disk-stage.page-stage {
  z-index: 2;
}

.disk-row {
  position: relative;
  z-index: 1;
  display: flex;
  gap: clamp(0.5rem, 3vw, 2rem);
}

.disk {
  position: relative;
  background: none;
  border: none;
  padding: 0;
  line-height: 0;
  cursor: pointer;
  width: clamp(80px, 20vw, 160px);
  -webkit-tap-highlight-color: transparent;
}

/* Corona rays — a real "zoom burst" texture generated from a plain
   ring PNG, the same one for every disk (js/disk.js draws dozens of
   scaled, faded copies onto a canvas, additively), not an unrelated
   pattern laid over each disk's own artwork: the streaks are literally
   real image pixels smeared outward, continuous (no gaps), just from a
   uniform ring rather than each disk's individual fill shape — see
   js/disk.js for why. inset: -82% sizes this box so the source image's
   own footprint inside the generated texture (38% of the canvas —
   RAY_TEXTURE_BASE_FRACTION) lines up with the real disk underneath
   it — this must stay tied to that constant, so don't resize this box
   to change how far the rays reach; that's the RAY_MAX_WIDTH macro in
   js/disk.js. Rotates with the disk (js/disk.js applies the same
   transform) since this is the light's own corona, not a fixed camera
   artifact. The texture itself already has the disk's own circle
   erased out of it, and a vignette-style fade toward its outer edge
   (RAY_CONTRAST/RAY_FEATHER macros — generateRayTexture in js/disk.js,
   destination-out then destination-in canvas operations). Both a
   two-mask-image mask-composite: intersect approach (for the circle
   exclusion) and a filter: blur() approach (for the edge fade) were
   tried at the CSS level here first — the former made the whole layer
   render invisible, and the latter softened the streaks themselves
   instead of just fading their opacity — so both now live in the
   generated PNG instead, not as CSS filter/masking. No filter
   property on this element at all, by design.

   The inconsistency comes from mask-image, not the texture itself: a
   feTurbulence noise pattern masks the rays, oversized (280%) and
   animated so it drifts slowly across them — the rays only show
   through wherever the noise is currently light, so which parts are
   visible keeps shifting instead of being static. The mask-image
   value itself is set from JS (js/disk.js, RAY_MASK_INTENSITY macro),
   not hardcoded here, since a CSS data: URI can't read a custom
   property's value into itself — everything else about the mask
   (repeat/size) stays here. The 240s duration below is just a fallback
   default — js/disk.js sets animation-duration inline from
   RAY_MASK_SPEED_SEC, which wins over this shorthand. mask-image and
   mix-blend-mode are on this one element together (not split across a
   parent/child) — same rule as .smoke-layer: a filter/mask on a
   *parent* would isolate a child's blend-mode from the real page
   backdrop and make it read as invisible. */
.disk-rays {
  position: absolute;
  inset: -82%;
  z-index: 0;
  pointer-events: none;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100% 100%;
  mix-blend-mode: screen;
  -webkit-mask-repeat: repeat;
  mask-repeat: repeat;
  -webkit-mask-size: 280% 280%;
  mask-size: 280% 280%;
  animation: disk-rays-mask-drift 240s linear infinite;
}

@keyframes disk-rays-mask-drift {
  from {
    -webkit-mask-position: 0% 0%;
    mask-position: 0% 0%;
  }
  to {
    -webkit-mask-position: 100% 100%;
    mask-position: 100% 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .disk-rays {
    animation: none;
  }
}

/* The glow (drop-shadow chain: NEAR/FAR/HALO/BLOOM_GLOW_PX in
   js/disk.js) used to live on .disk img itself, which is exactly why
   masking that same element for the vignette kept breaking: masking
   clips to the masked element's own box, and the glow deliberately
   bleeds up to 60px past it — every attempt at making the mask "big
   enough" to also spare the glow (oversized mask-size, rescaled
   percentages) produced its own new visible artifact instead of
   actually fixing it, because the two concerns don't belong on the same
   element in the first place.
   Cleaner fix: the glow now lives on this separate, UNMASKED element —
   same background-image as the disk (set once in js/disk.js, since
   each disk uses a different PNG), same box, but its own filter
   (drop-shadow only, applied in render()) and no mask-image at all. It
   bleeds exactly as freely as before there was ever a vignette, because
   there is nothing here to clip it. z-index: 0, same as .disk-rays —
   both sit behind the crisp .disk img, which now carries the vignette
   with a plain, un-oversized 100% mask (see .disk img below), since it
   no longer needs to make room for anything bleeding past its edges. */
/* inset: -100% physically enlarges this element to 3x the disk's own
   size (100% original + 100% margin each side), well past
   BLOOM_GLOW_PX's reach — a REAL box, not a mask-size trick on a
   smaller one. That distinction matters: the earlier disk-vignette
   attempt used mask-size to oversize just the MASK on a normal-sized
   element, and still produced a visible square (likely a filter+mask
   reference-frame interaction that was never fully diagnosed without a
   browser to inspect it in). A physically bigger box removes that
   ambiguity entirely — mask-image with no explicit mask-size just
   defaults to 100% of the element's own true border-box, so there's
   only one coordinate system in play, not two fighting each other.
   background-size 33.3333% shrinks the artwork back down to its
   correct/original size within this 3x-bigger box (100/3), and the
   feather gradient below uses the same "* 0.3333" scaling as
   --bloom-feather-inner/outer's comment describes, for the same reason:
   this element's own edge is now 300% of the disk's actual radius, so
   a value that means "100% of the disk's own radius" has to land at
   33.33% of THIS box to line up correctly. */
.disk-glow {
  position: absolute;
  inset: -100%;
  z-index: 0;
  pointer-events: none;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 33.3333% 33.3333%;
  will-change: filter;
  -webkit-mask-image: radial-gradient(circle closest-side, white calc(var(--bloom-feather-inner) * 0.3333), transparent calc(var(--bloom-feather-outer) * 0.3333));
  mask-image: radial-gradient(circle closest-side, white calc(var(--bloom-feather-inner) * 0.3333), transparent calc(var(--bloom-feather-outer) * 0.3333));
}

/* Per-disk vignette: a real mask-image, not a mix-blend-mode: multiply
   overlay (tried and reverted — multiply darkens whatever's BEHIND the
   disk too, the fog included, which isn't "opacity" at all). A mask
   only affects this element's own alpha, so the fog stays exactly as
   bright wherever the disk fades. Simple 100%-sized mask, no
   oversizing needed: now that the glow lives on the separate
   .disk-glow above, this element has nothing bleeding past its own box
   for the mask to accidentally clip. */
.disk img {
  position: relative;
  z-index: 1;
  width: 100%;
  display: block;
  transform: rotate(0deg);
  will-change: transform, filter;
  -webkit-mask-image: radial-gradient(circle closest-side, white var(--disk-vignette-inner), transparent var(--disk-vignette-outer));
  mask-image: radial-gradient(circle closest-side, white var(--disk-vignette-inner), transparent var(--disk-vignette-outer));
}

.disk:focus-visible {
  outline: 2px solid var(--fg);
  outline-offset: 4px;
}

/* Center disk's hub dot — a wholly separate overlay, NOT a child of any
   .disk button (it's a sibling of the three buttons, inside .disk-row
   instead), specifically so nothing here can ever collide with the
   .disk / .disk img / js/disk.js rules or its per-frame rotation logic.
   assets/disks/diskdot.png shares the exact same 1081x1081 canvas as
   disk2.png, so giving this the same width as .disk (clamp(80px, 20vw,
   160px)) and centering it on .disk-row's own midpoint lines it up on
   the middle disk without any per-disk coordinates. Static — it does
   not rotate with the disk. z-index: 0 ties it with every disk's own
   .disk-rays/.disk-glow (also 0 — .disk itself is position: relative
   with z-index: auto, so it never forks its own stacking context, and
   all of these compete directly as peers within .disk-row's) — same
   z-index means paint order falls back to DOM order, and this overlay
   is the LAST child of .disk-row, so it paints on top of every disk's
   glow/rays while still sitting under all three crisp .disk img
   elements (z-index: 1, always painted above the whole z:0 group
   regardless of DOM order). Net effect: still fully occluded by the
   ring's opaque body, but now wins (instead of getting washed out by)
   the disk's own white glow wherever both show through the same gap —
   the ring's transparent center hole, where disk2's halo/bloom layers
   bleed inward same as this dot's own glow does. A first pass at
   z-index: -1 (painting BEFORE disk-glow) let that white glow composite
   on top of the red there and desaturate it toward pink/gray; this is
   the fix. The drop-shadow chain uses the same 4 radii as js/disk.js's
   NEAR/FAR/HALO/BLOOM glow but pure saturated red instead of that
   glow's cool white, pushed to near-opaque throughout specifically so
   it reads as strongly, unmistakably red rather than a pale tint.
   Static and CSS-only, same as the rest of this rule — intentionally
   not wired to js/disk.js, so nothing there needs touching. */
.disk-dot-overlay {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 0;
  width: clamp(80px, 20vw, 160px);
  transform: translate(-50%, -50%) scale(1);
  pointer-events: none;
  transition: filter 0.2s ease, transform 0.2s ease;
  /* blur(calc(var(--dot-spin-blur, 0px) * var(--dot-spin-blur-mult, 1)))
     — js/disk.js's render() sets --dot-spin-blur (only on the center
     disk, index 1) to the exact same blur radius the disk's own crisp
     image gets while spinning, so the dot doesn't read as a crisp
     sticker sitting on top of an otherwise motion-blurring disk. Not
     the disk's rotation/skew or RGB split, just this one shared number
     — as asked, "it doesn't need to rotate or anything, just a little
     blur." --dot-spin-blur-mult (:root above, tunable on dev/tune.html)
     scales how much of it actually reaches the dot — 1 = exact match,
     0 = none. A single length*number multiplication, not the compound
     multi-term calc() that caused the earlier "whole filter goes
     invalid" bug, so safe to add. Composed as an extra filter function
     alongside the drop-shadows (not folded into any of them) so it
     applies uniformly underneath whichever drop-shadow set (rest/
     .playing/hover) is currently active — each of those replaces the
     WHOLE filter list, so this same blur() term is repeated in all
     three below rather than living in just one. */
  filter:
    blur(calc(var(--dot-spin-blur, 0px) * var(--dot-spin-blur-mult, 1)))
    drop-shadow(0 0 3px rgba(255, 0, 0, 1))
    drop-shadow(0 0 8px rgba(255, 0, 0, 1))
    drop-shadow(0 0 20px rgba(255, 0, 0, 0.85))
    drop-shadow(0 0 70px rgba(255, 0, 0, 0.65));
}

/* Persistent "now playing" glow — js/disk-dot.js adds this class the
   moment playback actually starts and removes it on stop/natural end.
   Values are rest + 60% of the way to the hover state's own numbers
   (transform included), so it reads as "60% of the hover glow stays on"
   the whole time audio is playing, not just while the cursor happens to
   be sitting on the dot. Plain rgba() alphas throughout (not calc()) —
   after the last CSS-variable-in-calc() bug fully killed the filter
   when a term went invalid, these are deliberately just fixed numbers
   with nothing to compute. .disk-dot-hover-target:hover's own rule
   below still wins outright while actually hovering: two classes here
   vs. a class+pseudo-class+class there is lower specificity, so hover
   always takes over regardless of source order. */
.disk-dot-overlay.playing {
  transform: translate(-50%, -50%) scale(1.18);
  filter:
    blur(calc(var(--dot-spin-blur, 0px) * var(--dot-spin-blur-mult, 1)))
    drop-shadow(0 0 4.8px rgba(255, 0, 0, 1))
    drop-shadow(0 0 12.8px rgba(255, 0, 0, 1))
    drop-shadow(0 0 32px rgba(255, 0, 0, 0.94))
    drop-shadow(0 0 106px rgba(255, 0, 0, 0.8));
}

/* Dedicated hover hit-target — a plain, invisible, round element sized
   by --dot-hover-diameter (tune it on dev/tune.html), NOT a clip-path on
   .disk-dot-overlay itself. That approach (clip-path + pointer-events +
   raised z-index all on the one visual element) tested correctly via
   elementFromPoint in headless Chrome, but was reported to still react
   at the disk's edge in a real browser after reloading — never fully
   root-caused, and not worth re-guessing at blind. This is a simpler,
   more isolated mechanism: its own small box, its own z-index, nothing
   layered with the visual dot's filter/transform, so there's less
   surface for a real-browser-only discrepancy to hide in. It sits
   BEFORE .disk-dot-overlay in the DOM (see index.html) specifically so
   the sibling-combinator rule below can reach forward to it. */
.disk-dot-hover-target {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 3;
  width: var(--dot-hover-diameter, 20px);
  height: var(--dot-hover-diameter, 20px);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  pointer-events: auto;
  cursor: pointer;
}

/* Brighter glow + bigger, only while hovering the dedicated hit-target
   above (not the disk, not the ring, just this) — reached via the
   general sibling combinator, since .disk-dot-overlay is a later
   sibling of .disk-dot-hover-target within .disk-row. scale(1.3) — half
   the size increase of the first pass (1.6, i.e. +60%) — plus notably
   stronger drop-shadow radii/alphas so the reaction still reads clearly
   against the disk's own separate, unrelated white glow (which doesn't
   react to this at all). */
.disk-dot-hover-target:hover ~ .disk-dot-overlay {
  transform: translate(-50%, -50%) scale(1.3);
  filter:
    blur(calc(var(--dot-spin-blur, 0px) * var(--dot-spin-blur-mult, 1)))
    drop-shadow(0 0 6px rgba(255, 0, 0, 1))
    drop-shadow(0 0 16px rgba(255, 0, 0, 1))
    drop-shadow(0 0 40px rgba(255, 0, 0, 1))
    drop-shadow(0 0 130px rgba(255, 0, 0, 0.9));
}

/* Click feedback — a brief, much bigger/brighter flash on top of
   whatever state (rest or hover) the dot was already in, so clicking
   reads as an unmistakable, distinct event rather than just "the hover
   effect, but you also heard a sound." js/disk-dot.js toggles this
   class on for CLICK_PULSE_MS then removes it; the transition above
   handles animating back out to rest/hover cleanly once it's gone. */
.disk-dot-overlay.pulse {
  animation: dot-click-pulse 0.18s ease-out;
}

@keyframes dot-click-pulse {
  0% {
    transform: translate(-50%, -50%) scale(1);
  }
  35% {
    transform: translate(-50%, -50%) scale(2.1);
    filter:
      drop-shadow(0 0 10px rgba(255, 60, 60, 1))
      drop-shadow(0 0 26px rgba(255, 60, 60, 1))
      drop-shadow(0 0 60px rgba(255, 60, 60, 1))
      drop-shadow(0 0 180px rgba(255, 60, 60, 0.95));
  }
  100% {
    transform: translate(-50%, -50%) scale(1);
  }
}

/* Ambient fog drifting across the whole page, dimly but uniformly
   visible everywhere — sitting above the disks (z-index 3 vs. their
   z-index 1), but NOT positionally tied to them. The actual "light
   hitting the fog" effect near each disk comes from that disk's own
   drop-shadow glow (js/disk.js), which already follows its exact
   artwork shape (a ring stays a ring, a half-disk glows only on its
   filled half) — a separate flat-gradient glow patch here read as an
   artificial, textureless blob and was dropped in favor of that.
   Fixed and viewport-sized (same technique as the vignette). Uses two
   real, pre-rendered fog textures (assets/fog/) rather than
   live-computed gradients — the earlier ~300-gradient version was
   expensive to rasterize every frame and made the page laggy; a
   couple of tiled PNGs plus a small blur per layer is dramatically
   cheaper. Each layer's texture is tiled twice inside a 200%-wide
   strip and scrolled via transform (compositor-only, no layout cost)
   exactly one tile-width per loop so it repeats with no visible seam;
   an opacity "breathing" keyframe on each layer adds movement without
   needing more image data. */
.smoke {
  position: fixed;
  inset: 0;
  z-index: 3;
  overflow: hidden;
  pointer-events: none;
}

.smoke-layer {
  position: absolute;
  inset: 0;
  width: 200%;
  mix-blend-mode: screen;
  background-repeat: repeat-x;
  background-size: 50% 100%;
  /* Blur lives on each layer, not the .smoke wrapper: a filter on the
     wrapper creates a stacking context that isolates mix-blend-mode,
     so these layers would blend against each other instead of the
     actual black page behind them, and it just reads as invisible.
     brightness() here is what --fog-brightness controls — it dims the
     source texture (near-white PNG) toward gray before blending. */
  filter: blur(var(--fog-blur)) brightness(var(--fog-brightness));
}

.smoke-layer-far {
  background-image: url("../assets/fog/fog2.png");
  animation-name: fog-scroll, fog-breathe-far;
  animation-duration: calc(70s / var(--fog-speed)), 21s;
  animation-timing-function: linear, ease-in-out;
  animation-iteration-count: infinite, infinite;
}

.smoke-layer-near {
  background-image: url("../assets/fog/fog1.png");
  animation-name: fog-scroll, fog-breathe-near;
  animation-duration: calc(40s / var(--fog-speed)), 15s;
  animation-timing-function: linear, ease-in-out;
  animation-iteration-count: infinite, infinite;
  animation-direction: reverse, normal;
}

@keyframes fog-scroll {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-50%);
  }
}

@keyframes fog-breathe-far {
  0% {
    opacity: calc(0.3 * var(--fog-intensity));
  }
  25% {
    opacity: calc(0.6 * var(--fog-intensity));
  }
  50% {
    opacity: calc(0.4 * var(--fog-intensity));
  }
  80% {
    opacity: calc(0.5 * var(--fog-intensity));
  }
  100% {
    opacity: calc(0.3 * var(--fog-intensity));
  }
}

@keyframes fog-breathe-near {
  0% {
    opacity: calc(0.35 * var(--fog-intensity));
  }
  22% {
    opacity: calc(0.7 * var(--fog-intensity));
  }
  40% {
    opacity: calc(0.48 * var(--fog-intensity));
  }
  58% {
    opacity: calc(0.62 * var(--fog-intensity));
  }
  80% {
    opacity: calc(0.42 * var(--fog-intensity));
  }
  100% {
    opacity: calc(0.35 * var(--fog-intensity));
  }
}

@media (prefers-reduced-motion: reduce) {
  .smoke-layer-far,
  .smoke-layer-near {
    animation: none;
    opacity: calc(0.45 * var(--fog-intensity));
  }

  .page-stage {
    animation: none;
    transition: none;
  }
}

/* Contact page */
.contact-links {
  position: relative;
  z-index: 1001;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  align-items: flex-start;
}

.contact-links a {
  color: var(--fg);
  font-size: 1.1rem;
  text-decoration: none;
  /* Transparent, not none — keeps the same layout space reserved so
     hover's border-color swap below doesn't shift anything, it just
     reveals the line rather than only underlining on hover. */
  border-bottom: 1px solid transparent;
}

.contact-links a:hover {
  border-color: var(--fg);
}

/* Shows page — populated by js/shows.js from a published Google Sheet.
   Both this and .empty-state start hidden (see shows.html) so the page
   shows nothing while the fetch is in flight; the script then reveals
   whichever one actually applies once it knows. */
.shows-list {
  position: relative;
  z-index: 1001;
  display: flex;
  flex-direction: column;
  gap: 3rem;
  align-items: center;
  width: 100%;
  max-width: 32rem;
  padding: 0;
  margin: 0;
  list-style: none;
}

.show {
  font-size: 1rem;
}

.show-ticket {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid var(--muted);
}

.show-ticket:hover {
  border-color: var(--fg);
}

