/* Reflection — the moonlit gramophone, the journey's last stop.
 *
 * Ported from demos/reflection-gramophone-layer-test.html. Every rule is scoped
 * under .reflection-scene because the demo owned a whole document and used bare
 * names (.stage, .track, .playlist, .background) that would otherwise collide
 * with Story Mode's own chrome.
 *
 * Unlike the rest of the journey this scene is not a 3D world at a camera
 * position — it is a fixed 16:9 plate. It sits above the canvas and below the
 * modal band, and it is shown or hidden by a hard cut, never a dissolve.
 */

.reflection-scene {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: grid;
  place-items: center;
  background: #05070d;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 240ms linear, visibility 0s linear 240ms;
}
.reflection-scene.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 240ms linear, visibility 0s linear 0s;
}

/* The whole 16:9 composition is always visible. Full-width crops the bottom of
   the frame on a short window — exactly where the record edge, the player and
   the plaques live — so the plate fits to whichever of width or height binds
   first. */
.reflection-scene {
  /* One ruler for the plate and for the wider backdrop that has to line up
     with it. Both read this, so they can never disagree. */
  --rf-stage-w: min(100vw, calc(100svh * 16 / 9));
  /* The backdrop is 1792x878 (center-locked v4). The whole plate was scaled
     down from the old 941 height to 878 -- 0.9309 -- so its middle resamples
     the original rather than repainting it, and the 16:9 view inside measures
     1557x876 with 117px of new painting on each side. 1792/1557 = 1.1509 stage
     widths. That number is the one thing to turn if the backdrop ever looks
     too large or too small behind the authored foreground. */
  --rf-wide-w: calc(var(--rf-stage-w) * 1.1509);

  /* Live-tunable groups (reflection-edit mode writes these). Defaults are
     identity -- 0 offset, 1x scale -- so the authored composition is exactly
     what it was until someone drags something. Offsets are in cqw so they
     scale with the stage instead of drifting on a different window. */
  /* Solved in reflection-edit mode against the center-locked v4 backdrop
     (2026-08-17). Sunny's numbers as she left them -- the long decimals are
     what a drag produces and they are hers, not mine to round. Everything
     lands within a few cqw of zero: the outpaint sits close to the authored
     composition, and the record only needed a size down and the faintest
     change of perspective. */
  --rf-rec-dx: -0.33947172619047566cqw; --rf-rec-dy: 0.6546954719387773cqw;
  --rf-rec-k: 0.952;                    --rf-rec-flat: 1.012;
  --rf-rig-dx: -2.445558301445577cqw;   --rf-rig-dy: 2.9449670493197235cqw;
  --rf-nav-dx: -0.14216624149659862cqw; --rf-nav-dy: -0.8020102572278912cqw;
  /* The spindle is its own group rather than riding the record: it is a fixed
     part of the machine, so it must not scale with the disc, and once the disc
     is resized or flattened its centre hole moves -- the pin has to be aimed
     at the new one by hand. */
  --rf-spin-dx: -0.4821362138605521cqw; --rf-spin-dy: -0.22985756802721108cqw;
}
.reflection-stage {
  position: relative;
  /* Everything inside sizes against the stage, not the browser — see the cqw
     units below. */
  container-type: inline-size;
  width: var(--rf-stage-w);
  aspect-ratio: 16 / 9;
  /* Was hidden. The record is authored past the left edge (-9.77%) and the
     tonearm past the right (+11.7%), and the clip used to be invisible because
     everything outside 16:9 was blur anyway. Now that the backdrop really
     continues out there, that clip reads as a cut frame across the plate --
     so the plate is allowed to finish. */
  overflow: visible;
  /* Transparent, not the old navy: the wide backdrop lies underneath now and
     this would cover it. */
  background: transparent;
  isolation: isolate;
}
/* The painted extension, centred on the stage and allowed past its edges.
   Sits above the blur and below the plate, so a window wider than 16:9 shows
   painting where it used to show a blurred smear -- and a window that is too
   short still gets the blur, which is the one thing paint cannot cover. */
.reflection-wide-bg {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: var(--rf-wide-w);
  aspect-ratio: 1792 / 878;
  z-index: 1;
  pointer-events: none;
}
.reflection-wide-bg img {
  width: 100%;
  height: 100%;
  display: block;
}

/* `100vw` remains the unrotated portrait width in the virtual-landscape
   fallback. Size from Story Mode's solved logical height so the complete 16:9
   finale plate is visible on both native and forced landscape phones. */
.reflection-stage { z-index: 2; }
html.is-mobile-storymode .reflection-stage {
  width:min(100%,calc(var(--stage-height) * 16 / 9));
  max-height:100%;
}

/* Whatever the fit leaves over is filled by the same background, scaled up and
   blurred, the way iOS Photos backs a mismatched aspect. Scaled past the edges
   so the blur never samples past the bitmap and shows a seam. */
.reflection-fill {
  position: absolute;
  inset: 0;
  z-index: 0;   /* under .reflection-wide-bg */
  overflow: hidden;
  pointer-events: none;
}
.reflection-fill img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.18);
  filter: blur(42px) saturate(.86) brightness(.62);
}

.reflection-scene .layer { position: absolute; user-select: none; pointer-events: none; }
.reflection-scene .background { inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }

/* Rotate the square master first, then project it into the scene ellipse. */
.reflection-scene .record-projection {
  left: -9.765625%; top: 39.583333%;
  width: 85.9375%; aspect-ratio: 1;
  z-index: 2;
  /* .390909 is the authored perspective: how flat the ellipse sits. --rf-rec-flat
     multiplies it rather than replacing it, so 1 is always "as drawn" and the
     tuner works in relative terms. Order matters: move, then size, then flatten. */
  transform:
    translate(var(--rf-rec-dx), var(--rf-rec-dy))
    scale(var(--rf-rec-k))
    scaleY(calc(.390909 * var(--rf-rec-flat)));
  transform-origin: 50% 0;
}
.reflection-scene .record {
  inset: 0;
  width: 100%; height: 100%;
  transform-origin: 50% 50%;
  animation: reflection-record-turn var(--reflection-turn-duration, 30s) linear infinite;
}
@keyframes reflection-record-turn { to { transform: rotate(-360deg); } }

/* Fixed spindle sits above the rotating record. */
.reflection-scene .spindle {
  left: 33.2%; top: 70.55%;
  width: 1.875%; height: auto;
  z-index: 4;
  /* Group offset first, then its own centring -- the -50%/-100% is what puts
     the pin's base at the point, and that must survive. */
  transform: translate(var(--rf-spin-dx), var(--rf-spin-dy)) translate(-50%, -100%);
}
.reflection-scene .tonearm { left: 48.4375%; top: 22.2222%; width: 63.28125%; height: 61.1111%; object-fit: contain; z-index: 6; }
.reflection-scene .sunny { left: 60.546875%; top: 39.50690%; width: 13.671875%; max-height: 38.1944%; object-fit: contain; z-index: 7; }
.reflection-scene .bag { left: 74.21875%; top: 52.80132%; width: 8.203125%; max-height: 12.5%; object-fit: contain; z-index: 8; }
/* The rig: tonearm, Sunny, her bag, the sparks off the needle, and the bag's
   hit target. They are one physical assembly -- she is sitting ON the arm and
   the sparks come off its needle -- so the tuner moves them together and never
   changes their spacing. The group offset goes FIRST in each transform, ahead
   of whatever that element already does for itself (the paused lift, the
   spindle's own centring), so nothing local is lost. */
.reflection-scene .tonearm,
.reflection-scene .sunny,
.reflection-scene .bag {
  --tonearm-lift: clamp(4px, .52cqw, 8px);
  --lift: 0px; --bag-hover: 0px;
  transform:
    translate(var(--rf-rig-dx), var(--rf-rig-dy))
    translateY(calc(-1 * (var(--lift) + var(--bag-hover))));
  transition: transform .72s cubic-bezier(.22, .72, .2, 1), filter .28s ease;
  will-change: transform;
}
.reflection-scene .spirit-field,
.reflection-bag-hotspot {
  transform: translate(var(--rf-rig-dx), var(--rf-rig-dy));
}
.reflection-stage.is-paused .tonearm,
.reflection-stage.is-paused .sunny,
.reflection-stage.is-paused .bag { --lift: var(--tonearm-lift); }

/* The bag is its own entrance. The hit target is authored a size larger than
   the bag rather than inferred from its alpha bounds, per the art-direction
   handbook — an alpha-derived target is unhittable on a small screen. Hover
   gives a little glass highlight and a very small lift, no scale pulse. */
.reflection-bag-hotspot {
  position: absolute; left: 72.4%; top: 50.27%; width: 12.2%; height: 13.6%;
  padding: 0; border: 0; background: transparent; border-radius: 26%;
  cursor: pointer; pointer-events: auto; z-index: 10;
}
.reflection-bag-hotspot:focus-visible { outline: none; }
/* The site's own bag hotspot dot, carried over in this scene's brass. */
.reflection-bag-dot {
  position: absolute; left: 50%; top: 44%; translate: -50% -50%;
  width: clamp(9px, .92cqw, 18px); aspect-ratio: 1; border-radius: 50%;
  border: max(1px, .1cqw) solid #fff3cfea; background: #ffd89233;
  box-shadow: 0 0 0 .28em #ffe7b016, 0 0 12px #ffd48caa;
  animation: reflection-bag-pulse 2.25s ease-in-out infinite;
}
@keyframes reflection-bag-pulse {
  0%, 100% { transform: scale(1); opacity: .82; }
  50% { transform: scale(1.16); opacity: 1; }
}
.reflection-bag-hotspot:hover .reflection-bag-dot,
.reflection-bag-hotspot:focus-visible .reflection-bag-dot {
  background: #ffe0a355; box-shadow: 0 0 0 .34em #ffe7b024, 0 0 16px #ffdc9fcc;
}
.reflection-stage:has(.reflection-bag-hotspot:hover) .bag,
.reflection-stage:has(.reflection-bag-hotspot:focus-visible) .bag {
  --bag-hover: clamp(2px, .26cqw, 4px);
  filter: drop-shadow(0 0 6px #ffd6a0cc) brightness(1.1);
}
/* The same label the bag wears everywhere else in the journey, down to the
   type and the pale pill — this is site chrome, not scene artwork, so it must
   not restyle itself per world. It also sits above the dot, as it does in every
   other chapter, rather than below it. */
.reflection-bag-label {
  position: absolute;
  left: 50%; bottom: calc(56% + 14px);
  width: max-content; max-width: 140px;
  padding: 6px 10px;
  border: 1px solid rgba(255, 255, 255, .8);
  border-radius: 999px;
  color: #174f58;
  background: rgba(247, 255, 247, .92);
  box-shadow: 0 7px 20px rgba(7, 75, 80, .14);
  font: 600 10px/1.2 var(--mono, "IBM Plex Mono", ui-monospace, monospace);
  letter-spacing: .04em;
  opacity: 0;
  transform: translate(-50%, 5px);
  transition: opacity 150ms ease, transform 180ms ease;
  pointer-events: none;
}
.reflection-bag-hotspot:hover .reflection-bag-label,
.reflection-bag-hotspot:focus-visible .reflection-bag-label { opacity: 1; transform: translate(-50%, 0); }

/* Its bottom-centre is pinned to the fine needle tip, not the cartridge body. */
.reflection-scene .spirit-field {
  left: 52.28%; top: 61.95%; width: 5%; height: 13.2%; z-index: 9;
  opacity: 1;
  transition: opacity .34s ease .56s;
}
.reflection-stage.is-paused .spirit-field { opacity: 0; transition-delay: 0s; }
.reflection-scene .spirit {
  position: absolute;
  left: 50%; bottom: 0;
  width: clamp(4px, .42cqw, 8px); height: clamp(4px, .42cqw, 8px);
  border-radius: 50%;
  background: #eaffdc;
  box-shadow: 0 0 8px #ddffd8, 0 0 14px #ffe9a9;
  opacity: 0;
  animation: reflection-spirit-rise 3s ease-in-out infinite;
}
/* Hottest where they are born, cooling as they rise. The heat rides filter
   rather than background and box-shadow: the cross-star variant clears both of
   those, and an animation would outrank it and turn the stars back into dots.
   Brightness also stays under the clipping point — pushing it past 2 drove all
   three channels to 255 and paid for the brightness with the colour. */
@keyframes reflection-spirit-rise {
  0% { translate: 0 0; scale: .25; opacity: 0;
       filter: brightness(1.5) saturate(1.45) drop-shadow(0 0 4px #ffb257cc); }
  6% { opacity: 1; }
  26% { filter: brightness(1.18) saturate(1.2) drop-shadow(0 0 2px #ffca8a00); }
  55% { opacity: .72; }
  100% { translate: var(--drift, 8px) var(--rise, -82px); scale: 1; opacity: 0;
         filter: brightness(1) saturate(1) drop-shadow(0 0 0 #ffca8a00); }
}
/* A larger, greener, softer grade mixed in with the small white points so the
   field has two sizes and two temperatures. */
.reflection-scene .spirit.ember {
  width: clamp(6px, .72cqw, 14px); height: clamp(6px, .72cqw, 14px);
  background: #cfffbe;
  box-shadow: 0 0 9px #a9ff9bcc, 0 0 20px #6dffae66, 0 0 34px #3cffc033;
}
.reflection-scene .spirit.star { border-radius: 0; background: transparent; box-shadow: none; }
.reflection-scene .spirit.star::before,
.reflection-scene .spirit.star::after {
  content: ""; position: absolute; inset: 50% auto auto 50%; translate: -50% -50%;
  width: 2px; height: 15px; border-radius: 100%;
  background: linear-gradient(transparent, #fffbd6, transparent);
  filter: drop-shadow(0 0 4px #fff3a8);
}
.reflection-scene .spirit.star::after { rotate: 90deg; }

.reflection-playlist {
  /* The global Sunny Wang mark occupies this same corner. Keep a full
     wordmark-height of breathing room below it at every fitted stage size. */
  --reflection-brand-clearance: clamp(42px, 3.8cqw, 62px);
  position: absolute; left: 4.3%; top: calc(5.4% + var(--reflection-brand-clearance)); width: 38%; z-index: 12;
  display: grid; color: #d6d5cc;
  text-shadow: 0 2px 8px #06101d;
}
.reflection-track-list {
  display: grid;
  gap: max(10.4px, .944cqw);
}
.reflection-playlist-title {
  margin: 0 0 clamp(8px, .72cqw, 13px); color: #c5ad78;
  display: grid; gap: clamp(2px, .18cqw, 4px);
}
.reflection-playlist-kicker {
  font: 500 clamp(6px, .5cqw, 9px)/1.2 Georgia, serif;
  letter-spacing: .26em;
}
.reflection-playlist-heading {
  font: 500 max(10px, .812cqw)/1.15 Georgia, serif;
  letter-spacing: .3em;
}
.reflection-track {
  appearance: none; position: relative; border: 0; padding: 0; background: none; color: inherit;
  display: flex; align-items: baseline; gap: clamp(7px, .75cqw, 13px); text-align: left; cursor: pointer;
  opacity: .42; transform-origin: left center;
  transition: opacity .32s ease, transform .32s ease, color .32s ease, text-shadow .32s ease;
}
.reflection-track-title {
  color: inherit; white-space: nowrap;
  font: 400 clamp(13px, 1.35cqw, 22px)/1.16 "Snell Roundhand", "Apple Chancery", cursive;
  letter-spacing: .018em;
}
.reflection-track-number {
  display: inline-block; min-width: 2.3em; margin-right: .28em;
  font-family: Georgia, serif; font-size: .52em; font-style: normal;
  letter-spacing: .16em; vertical-align: .22em;
}
.reflection-track-scene {
  color: #9da7a0; white-space: nowrap;
  font: 500 clamp(6px, .5cqw, 9px)/1.2 system-ui, sans-serif;
  letter-spacing: .14em; text-transform: uppercase;
}
.reflection-track:hover, .reflection-track:focus-visible {
  opacity: .82; color: #f4e8c1; transform: translateX(6px) scale(1.025);
  text-shadow: 0 0 9px #e6d7a466, 0 2px 9px #06101d; outline: none;
}
/* The playing row is the only one that grows, and its scale is derived rather
   than picked: the moon's glow starts at 27.57% of stage width, the unscaled
   title runs 17.32% from a row origin at 5.044%, so anything up to 1.262 clears
   it. Track 01 is the only title long enough to reach. */
.reflection-track.is-active {
  color: #fff4c9; opacity: 1; transform: translateX(10px) scale(1.26);
  text-shadow: 0 0 7px #fff0a6, 0 0 18px #ccffbd, 0 2px 9px #06101d;
}
.reflection-track.is-active .reflection-track-scene { color: #d1c7a5; }
.reflection-track.is-active::before {
  content: "\2726"; position: absolute; left: -18px; top: 50%; translate: 0 -50%;
  width: 1em; display: grid; place-items: center; line-height: 1; color: #dcffc4;
}

/* Stage-relative, not viewport-relative, so the shell stays registered to the
   painted plinth at every window size. */
.reflection-player {
  position: absolute;
  /* Centred on the record, not on a number of its own. 33.203125% is where the
     disc's own centre is by construction (-9.765625% + 85.9375%/2), and adding
     the record's tune keeps the two on one axis however the disc is moved --
     the old fixed 33.2% drifted off it the moment the disc was nudged. cqw and
     % are the same unit here: both are 1% of the stage. */
  left: calc(33.203125% + var(--rf-rec-dx)); translate: -50% 0;
  bottom: 1.8%;
  width: 46%; z-index: 13;
  aspect-ratio: 1933 / 339;
  --ring-centre-y: 33.61%; color: #ddc99b; text-shadow: 0 1px 5px #000;
}
.reflection-player-shell { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.reflection-player-title {
  position: absolute; left: 6.99%; top: 24.89%; width: 22.07%; height: 25.97%; overflow: hidden;
  padding-inline: .95em .7em;
  display: flex; align-items: center; justify-content: center; gap: .62em; text-overflow: ellipsis;
  white-space: nowrap; font: 500 max(7px, .72cqw)/1 Georgia, serif; color: #fff0b5;
  text-align: center; text-shadow: 0 0 7px #f8dd76aa, 0 2px 5px #000;
}
.reflection-player-title svg { width: 1.34em; height: 1.34em; flex: none; fill: #ffe692; filter: drop-shadow(0 0 3px #f8dd76aa); }
/* A short name simply sits centred; only a name too long to fit starts moving,
   and the window takes the remaining width. */
.reflection-title-window { min-width: 0; flex: 0 1 auto; overflow: hidden; translate: 0 -.02em; }
.reflection-title-track { display: inline-flex; gap: 2.8em; white-space: nowrap; will-change: transform; }
.reflection-title-copy { flex: none; }
.reflection-player-title.is-scrolling .reflection-title-track {
  animation: reflection-marquee var(--marquee-duration, 12s) linear infinite;
}
/* Paused, the strip parks too — the same breath as the record. */
.reflection-stage.is-paused .reflection-player-title.is-scrolling .reflection-title-track { animation-play-state: paused; }
@keyframes reflection-marquee { to { transform: translateX(calc(-1 * var(--marquee-span, 0px))); } }

.reflection-player-top { position: absolute; inset: 0; }
/* Registered to the brass rings painted in the shell, not eyeballed. Measured
   off the 1933x339 master along the ring centre row: the small ring centres are
   the midpoints of the widest bright chord inside each well's window, which is
   exact regardless of the scan row. Both axes are centred so each control has
   exactly one tunable centre, and the glyph inside is square by construction
   with its diameter as the only handle. */
.reflection-transport {
  appearance: none; position: absolute; top: var(--ring-centre-y); translate: -50% -50%;
  border: 0; border-radius: 50%;
  width: 6.42%; aspect-ratio: 1; padding: 0; background: transparent; color: #f2d690; cursor: pointer;
  display: grid; place-items: center;
}
.reflection-transport svg {
  position: absolute; left: 50%; top: 50%; translate: -50% -50%;
  width: 63.61%; aspect-ratio: 1; height: auto;
  overflow: visible; fill: #f2d690;
  filter: drop-shadow(0 0 5px #f5d77588) drop-shadow(0 2px 2px #000);
}
#reflectionPrevious { left: 41.02%; }
#reflectionToggle { left: 52.32%; }
#reflectionNext { left: 63.37%; }
#reflectionPrevious, #reflectionNext { top: 34.39%; }
.reflection-transport.is-primary { width: 8.87%; color: #fff0aa; }
.reflection-transport.is-primary svg { width: 64.21%; fill: #fff0aa; }
.reflection-pause-icon { display: none; }
.reflection-transport.is-playing .reflection-play-icon { display: none; }
.reflection-transport.is-playing .reflection-pause-icon { display: block; }

/* The painted slot is narrower than the control needs, so the speaker and the
   rail are allowed past the aligned box rather than squeezed into it. */
.reflection-volume { position: absolute; left: 75.69%; top: 31.58%; width: 19.47%; height: 10.83%; overflow: visible; }
.reflection-mute {
  position: absolute; left: -8.99%; top: 50%; translate: 0 -50%;
  width: 29.99%; aspect-ratio: 1; padding: 0; border: 0; background: transparent;
  display: grid; place-items: center; cursor: pointer; pointer-events: auto;
}
.reflection-mute:hover .reflection-volume-icon,
.reflection-mute:focus-visible .reflection-volume-icon { fill: #fff0b5; }
.reflection-mute:focus-visible { outline: none; }
.reflection-volume-icon { width: 100%; height: 100%; fill: #f0d68f; filter: drop-shadow(0 0 4px #f5d77566); overflow: visible; }
.reflection-volume-icon .ico-cross { display: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.reflection-mute[aria-pressed="true"] .reflection-volume-icon { fill: #8d8778; color: #8d8778; }
.reflection-mute[aria-pressed="true"] .ico-waves { display: none; }
.reflection-mute[aria-pressed="true"] .ico-cross { display: block; }

/* The aligned box is the hit area; the visible gold line is drawn by the track
   so the bar stays thin while the grab target matches the art. */
.reflection-volume input,
.reflection-timeline input { appearance: none; background: transparent; cursor: pointer; margin: 0; }
.reflection-volume input { height: 2px; border-radius: 9px; background: #bd9858; }
.reflection-timeline input::-webkit-slider-runnable-track { height: 2px; border-radius: 9px; background: #bd9858; }
.reflection-timeline input::-moz-range-track { height: 2px; border-radius: 9px; background: #bd9858; }
.reflection-volume input { position: absolute; left: 27%; top: 50%; translate: 0 -50%; width: 70%; pointer-events: auto; }
.reflection-volume input::-webkit-slider-thumb,
.reflection-timeline input::-webkit-slider-thumb {
  appearance: none; width: clamp(5px, .55cqw, 9px); aspect-ratio: 1; border-radius: 50%;
  border: 1px solid #e1c37c; background: #aa8249; box-shadow: 0 0 6px #f5de87;
}
/* The rail is a 2px track inside a tall hit area, and WebKit lines the thumb up
   with the track's top edge rather than its centre — so it has to be lifted by
   half its own height minus half the track. */
.reflection-timeline input::-webkit-slider-thumb { margin-top: calc((2px - clamp(5px, .55cqw, 9px)) / 2); }
.reflection-timeline { position: absolute; inset: 0; font-size: max(7px, .78cqw); pointer-events: none; }
.reflection-timeline time {
  position: absolute; display: grid; place-items: center;
  padding: 0; line-height: 1; text-align: center; color: #d8bb7e;
}
/* One baseline, one height: the size comes from .reflection-timeline, so the
   two plates have to be equal boxes to actually read as equal. */
#reflectionElapsed  { left: 8.73%;  top: 68.49%; width: 8.53%; height: 20.93%; }
#reflectionDuration { left: 83.41%; top: 68.49%; width: 7.39%; height: 20.93%; }
.reflection-timeline input { position: absolute; left: 20.65%; top: 72.50%; width: 59.30%; height: 14%; pointer-events: auto; }

/* The plaque knobs share the player's rivet line: the rivet sits at 15.69% of
   the 339px shell and the knob at 18.88% of the 724px plaque, which put the
   plaques 1.415% of stage height high. The gap must not be capped either, or a
   cap would change the grid's column width on a large screen and drift the
   line straight back out. */
.reflection-nav {
  /* Four plaques now: chat sits in About's old slot and the original three
   * shift right one seat each -- the row grows RIGHTWARD so plaque size and
   * pitch stay identical and the music player is untouched. */
  /* The v2 plaque set fills ~85% of its canvas (v1 kept ~69%), so the row
   * narrows to keep the rendered plaques the same size in the same place. */
  position: absolute; left: 60.3%;
  bottom: .385%;
  width: 31.2%; z-index: 14;
  /* Moves as a row; the four keep their pitch. */
  transform: translate(var(--rf-nav-dx), var(--rf-nav-dy));
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: end; gap: max(3px, .45cqw);
}
.reflection-nav button {
  appearance: none; display: block; width: 100%; padding: 0; border: 0; background: transparent; cursor: pointer;
  filter: drop-shadow(0 .35cqw .5cqw #0009); transform-origin: 50% 100%;
  transition: transform 180ms ease, filter 180ms ease;
}
.reflection-nav button:hover, .reflection-nav button:focus-visible {
  transform: translateY(-3%) scale(1.035);
  filter: drop-shadow(0 .45cqw .7cqw #000b) drop-shadow(0 0 .45cqw #f1d58d66);
  outline: none;
}
.reflection-nav img { display: block; width: 100%; height: auto; }
/* One shared breath for every glowing plaque: the nav itself runs a slow
 * clock on --nav-glow, and glowing buttons only read it -- so About's ready
 * halo and Chat's unread halo are always perfectly in phase, no matter when
 * each lit up. Warm amber, slowed to fit the vinyl room's hush. */
@property --nav-glow {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
.reflection-nav { animation: reflection-glow-clock 3.8s ease-in-out infinite; }
@keyframes reflection-glow-clock {
  0%, 100% { --nav-glow: 0; }
  50% { --nav-glow: 1; }
}
.reflection-nav button[data-plaque="about"].is-book-ready,
.reflection-nav button[data-plaque="chat"].is-unread {
  filter: drop-shadow(0 .35cqw .5cqw #0009)
    drop-shadow(0 0 calc(.18cqw + .26cqw * var(--nav-glow)) rgba(245, 208, 138, calc(.32 + .44 * var(--nav-glow))))
    drop-shadow(0 0 calc(.7cqw * var(--nav-glow)) rgba(255, 196, 110, calc(.34 * var(--nav-glow))));
}

.reflection-stage.is-paused .record,
.reflection-stage.is-paused .spirit { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .reflection-bag-dot,
  .reflection-bag-label { animation: none; transition: none; }
  .reflection-player-title.is-scrolling .reflection-title-track { animation: none; }
  .reflection-nav button[data-plaque="about"].is-book-ready {
    animation: none;
    filter: drop-shadow(0 .35cqw .5cqw #0009)
      drop-shadow(0 0 .42cqw rgba(255,255,255,.72));
  }
}

/* The crossing into the finale is a plain fade to black. It has to cover both
   the river canvas and the plate (30) so the swap happens unseen, but it stays
   under the header (40): during a long crossing the top bar is the only thing
   telling you the page is still alive and still travelling. Opacity is written
   per frame from the camera, so scrolling back up plays the fade in reverse. */
.reflection-blackout {
  position: fixed;
  inset: 0;
  z-index: 34;
  background: #000;
  opacity: 0;
  pointer-events: none;
  overflow: hidden;
  /* No transition on opacity: this is a cut. Anything that eases here puts the
     two worlds on screen together, which is the exact thing the cover exists
     to prevent. */
}
/* Not an empty black: the record Sunny just went into, drawn rather than
   photographed. Same anatomy as the plate's own vinyl — a label at the centre,
   a run-out band, hundreds of fine grooves in brass, and one specular sweeping
   across them. Sized in vmax and cropped by the viewport for the same reason
   the painted record is cropped: the disc is enormous and we are close to it.
   Concentric rings cannot show their own rotation, so the turning is carried by
   the sheen and by a faint conic wear on the disc itself. */
.reflection-blackout-disc,
.reflection-blackout-sheen {
  position: absolute;
  left: 50%; top: 50%;
  width: 150vmax; aspect-ratio: 1;
  translate: -50% -50%;
  border-radius: 50%;
}
.reflection-blackout-disc {
  background:
    /* wear, so the disc reads as turning even though the rings are concentric */
    conic-gradient(from 0deg at 50% 50%,
      rgba(255, 255, 255, .014) 0deg, transparent 60deg,
      rgba(255, 255, 255, .022) 172deg, transparent 250deg,
      rgba(255, 255, 255, .012) 340deg, rgba(255, 255, 255, .014) 360deg),
    /* spindle hole */
    radial-gradient(circle closest-side at 50% 50%, #000 0 .62%, transparent .62%),
    /* label: warm paper with a brass rim */
    radial-gradient(circle closest-side at 50% 50%,
      rgba(196, 158, 96, .19) 0 7.1%,
      rgba(236, 202, 132, .30) 7.1% 7.45%,
      transparent 7.45%),
    /* run-out band between the label and the music */
    radial-gradient(circle closest-side at 50% 50%,
      transparent 0 8.5%,
      rgba(226, 190, 118, .16) 8.5% 8.8%,
      transparent 8.8%),
    /* the wider brass rings that separate the tracks */
    repeating-radial-gradient(circle closest-side at 50% 50%,
      rgba(232, 196, 124, .11) 0 .16%,
      transparent .16% 3.2%),
    /* the grooves themselves */
    repeating-radial-gradient(circle closest-side at 50% 50%,
      rgba(206, 170, 104, .085) 0 1px,
      rgba(0, 0, 0, 0) 1px 4px),
    #000;
  /* The idle spin animates the `rotate` property, and an animation outranks a
     plain declaration — so the scroll-driven turn rides `transform` instead.
     They are separate channels and compose, which is what lets the record both
     idle and answer the wheel. */
  transform: rotate(var(--reflection-scroll-turn, 0deg));
  animation: reflection-groove-turn 26s linear infinite;
}
.reflection-blackout-sheen {
  /* A wide, low band rather than a narrow bright wedge — a narrow one reads as
     a lamp cone pointing at the disc instead of light running along a groove. */
  background: conic-gradient(from 0deg at 50% 50%,
    transparent 0deg,
    rgba(255, 232, 176, .035) 18deg,
    rgba(255, 244, 208, .075) 52deg,
    rgba(255, 248, 222, .095) 74deg,
    rgba(206, 228, 255, .055) 104deg,
    rgba(180, 206, 240, .022) 132deg,
    transparent 158deg,
    transparent 360deg);
  /* Kept off the label and faded well before the rim, so the sweep travels
     across the grooves rather than washing the whole disc. */
  mask-image: radial-gradient(circle closest-side at 50% 50%,
    transparent 0 8%, #000 14% 44%, transparent 62%);
  mix-blend-mode: screen;
  animation: reflection-groove-turn 8s linear infinite;
}
@keyframes reflection-groove-turn { to { rotate: 360deg; } }
@media (prefers-reduced-motion: reduce) {
  .reflection-blackout-disc,
  .reflection-blackout-sheen { animation: none; }
}

/* Two layers outrank the blackout and would otherwise float on the black: the
   airship (64) and a latched hover card (62). Both stand down for the whole
   crossing, not just once the plate has arrived. The header is the deliberate
   exception — it is what says the page is still travelling. */
.is-reflection-crossing .airship-controls,
.is-reflection-crossing .airship-visual-overlay { display: none; }
.is-reflection-crossing .hotspot-callout-floating { opacity: 0; }

/* The journey chrome belongs to the worlds behind, not to the record room --
   the finale carries its own plaques and its own player. */
.is-reflection-scene .journey-route,
.is-reflection-scene .scroll-hint,
.is-reflection-scene .journey-chapter { opacity: 0; pointer-events: none; }

/* The wall's hint. No constructed tail: a rotated square tacked on the side
   reads as assembled the moment it lands on a busy background. The shape does
   the pointing instead — every corner rounded except the one nearest Sunny,
   which is where a speech bubble's corner would be. */
/* The chat bubble it inherits from sits at z-index 12, which is under the fall
   overlay at 199 — so on both void crossings the hint was rendered and then
   buried. It has to clear the covers it is meant to speak over. */
.crossing-bubble.sunny-chat-bubble {
  z-index: 205;
  left: var(--crossing-bubble-x, calc(50% + clamp(112px, 11vw, 196px)));
  bottom: auto;
  top: var(--crossing-bubble-y, 60%);
  /* As wide as its longest line, not as wide as the box it could wrap in —
     the copy carries its own break, so nothing has to be left over on the
     right. */
  width: max-content;
  max-width: min(46vw, 20em);
  padding: 11px 17px;
  border: 1px solid rgba(255, 255, 255, .8);
  border-radius: 5px 20px 20px 20px;
  color: #174f58;
  /* Lighter than opaque, with the background softened behind it, so it settles
     onto the artwork instead of sitting on top of it as a solid card. */
  background: rgba(247, 255, 250, .74);
  backdrop-filter: blur(7px) saturate(1.05);
  -webkit-backdrop-filter: blur(7px) saturate(1.05);
  box-shadow: 0 6px 20px rgba(7, 75, 80, .13), 0 0 22px rgba(255, 255, 255, .5);
  font: 600 14px/1.45 var(--mono, "IBM Plex Mono", ui-monospace, monospace);
  letter-spacing: .03em;
  text-align: left;
  white-space: normal;
}
.crossing-bubble.sunny-chat-bubble::after { content: none; }
/* Sitting on her left instead means the square corner swaps sides, so the
   shape still points back at her. */
/* Anchored by its right edge instead, so it can sit close to her however wide
   the text turns out — anchoring the left edge left the gap growing with the
   bubble. */
.crossing-bubble.sunny-chat-bubble.on-left {
  border-radius: 20px 5px 20px 20px;
  left: auto;
  right: var(--crossing-bubble-right, 15%);
}

/* Once the wall opens, the speech pill turns into the same three-step motion
   cue used at Start. It stays beside Sunny, so the central status can explain
   what happened while the chevrons answer the practical question: which way? */
.crossing-bubble.sunny-chat-bubble.is-ready-chevrons {
  display: flex;
  align-items: center;
  gap: clamp(2px, .45vw, 6px);
  width: auto;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  transform:none;
  transition:opacity 180ms ease-out;
}
.crossing-bubble.sunny-chat-bubble.is-ready-chevrons.is-leaving-chevrons {
  pointer-events:none;
}
.crossing-bubble.is-ready-chevrons i {
  width: clamp(18px, 2.2vw, 34px);
  height: clamp(18px, 2.2vw, 34px);
  border-top: clamp(3px, .38vw, 6px) solid;
  border-right: clamp(3px, .38vw, 6px) solid;
  border-radius: 2px;
  transform: rotate(45deg);
  opacity: .5;
  filter: drop-shadow(0 0 3px rgba(31, 122, 158, .85))
          drop-shadow(0 2px 9px rgba(31, 122, 158, .7))
          drop-shadow(0 0 18px rgba(214, 244, 255, .8));
  /* Exactly the opening cue's cadence and transform path. Reusing the same
     keyframe keeps this from becoming a merely similar, barely moving echo. */
  animation: start-cue-drift 1.55s ease-in-out infinite;
}
.crossing-bubble.is-ready-chevrons i:nth-child(1) { border-color: #fff; }
.crossing-bubble.is-ready-chevrons i:nth-child(2) { border-color: #dcf1fa; animation-delay: .16s; }
.crossing-bubble.is-ready-chevrons i:nth-child(3) { border-color: #9fd9ec; animation-delay: .32s; }
.crossing-bubble.is-ready-chevrons.points-back i { transform: rotate(-135deg); }
.crossing-bubble.is-ready-chevrons.points-back i {
  animation-name: crossing-ready-drift-back;
}
@keyframes crossing-ready-drift-back {
  0%, 62%, 100% { opacity:.5; transform:rotate(-135deg) translate(0,0); }
  28% { opacity:1; transform:rotate(-135deg) translate(4px,-4px); }
}

/* The crossing's own voice. Silent while the transition is merely working —
   a message every time would turn a transition into a loading screen — and
   only speaks when the visitor is actually being made to wait, or to tell
   them the wait is over. */
/* Sits just under the record's label rather than down at the foot of the
   frame. Near the bottom it was far enough from where the eye already is —
   the turning centre — to be missed entirely. The label's radius is 7.1% of a
   150vmax disc measured closest-side, i.e. about 5.3vmax, so this clears it
   with a little air. */
/* Shared by every crossing, not just the record's. Each transition puts it
   where its own motion is centred — under the record's label, at the centre of
   the flight's orbit — by writing these two variables. */
.crossing-status {
  position: fixed;
  z-index: 36;
  left: var(--crossing-status-x, 50%);
  top: var(--crossing-status-y, calc(50% + 10.4vmax));
  translate: -50% 0;
  display: grid;
  justify-items: center;
  gap: .9em;
  opacity: 0;
  pointer-events: none;
  transition: opacity 420ms ease;
}
.crossing-status.is-centred { translate: -50% -50%; }
/* Centring the grid would centre the copy *and* the arrow's reserved row, which
   pushed the line a half-arrow above the point it is supposed to sit on. The
   arrow leaves the flow here and hangs off the bottom instead, so the block is
   exactly the copy and the copy is exactly on centre — in both states. */
.crossing-status.is-centred .crossing-status-arrow {
  position: absolute;
  left: 50%;
  top: 100%;
  translate: -50% .8em;
}
/* Arrived from the far side, so the way on is the way back: the arrow moves
   above the line and turns over. Same chevron, same glow — it is still just
   indicating a direction, and the direction is up. */
.crossing-status.is-centred.points-back .crossing-status-arrow {
  top: auto;
  bottom: 100%;
  translate: -50% -.8em;
}
.crossing-status.is-visible { opacity: 1; }
/* Two tones, because this layer is shared: the record room is dark and takes
   warm gold, the flight sits on pale sky where that gold vanishes. */
/* Three tones, because this layer crosses three kinds of background: warm gold
   on the dark record and the void, ink on pale sky and yarn, and white in the
   water — where ink turns muddy and gold goes chalky. */
.crossing-status.tone-ink .crossing-status-line {
  color: #1d4f63;
  text-shadow: 0 0 16px #ffffffcc, 0 1px 2px #ffffff;
}
.crossing-status.tone-ink .crossing-status-arrow {
  filter: drop-shadow(0 0 6px #ffffffdd);
}
.crossing-status.tone-ink .crossing-status-arrow i { border-color:#1d4f63; }
.crossing-status.tone-white .crossing-status-line {
  color: #ffffff;
  text-shadow: 0 0 18px rgba(8, 64, 96, .45), 0 1px 3px rgba(8, 64, 96, .35);
}
.crossing-status.tone-white .crossing-status-arrow {
  filter: drop-shadow(0 0 7px rgba(8, 64, 96, .4));
}
.crossing-status.tone-white .crossing-status-arrow i { border-color:#fff; }
.crossing-status-line {
  margin: 0;
  /* The grid centres the block; without this the second line still sets flush
     left inside it. */
  text-align: center;
  color: #e8d6a8;
  font: 400 clamp(16px, 1.5vw, 25px)/1.5 Georgia, serif;
  letter-spacing: .06em;
  text-shadow: 0 0 14px #00000099;
}
/* The arrow only appears with the ready message, and points the way out. Small
   and lit rather than large and drawn — at 1.5em with a 2px stroke it read as a
   UI control asking to be clicked, when all it should do is indicate a
   direction. */
.crossing-status-arrow {
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:clamp(2px,.28vw,5px);
  width:clamp(26px,3vw,46px);
  height:clamp(42px,4.4vw,68px);
  opacity: 0;
  filter:drop-shadow(0 0 5px #f0d79a) drop-shadow(0 0 15px #e8c98099);
  transition: opacity 420ms ease;
}
.crossing-status-arrow i {
  width:clamp(11.52px,1.376vw,20.48px);
  height:clamp(11.52px,1.376vw,20.48px);
  border-right:clamp(1.28px,.154vw,2.56px) solid #f3e3ba;
  border-bottom:clamp(1.28px,.154vw,2.56px) solid #f3e3ba;
  border-radius:2px;
  rotate:45deg;
  opacity:.52;
}
.crossing-status.points-back .crossing-status-arrow {
  flex-direction:column-reverse;
}
/* Non-centred crossings keep the arrow in the grid flow. Reversing the
   chevron itself was only half the job: without also swapping the two grid
   rows, an up arrow still sat below the sentence. */
.crossing-status.points-back:not(.is-centred) .crossing-status-arrow { grid-row:1; }
.crossing-status.points-back:not(.is-centred) .crossing-status-line { grid-row:2; }
.crossing-status.points-back .crossing-status-arrow i { rotate:-135deg; }
.crossing-status.is-ready .crossing-status-arrow {
  opacity: .9;
}
.crossing-status.is-ready .crossing-status-arrow i {
  animation:crossing-arrow-nudge 1.55s ease-in-out infinite;
}
.crossing-status.is-ready .crossing-status-arrow i:nth-child(2) {
  animation-delay:.18s;
}
.crossing-status.is-ready.points-back .crossing-status-arrow i {
  animation-name:crossing-arrow-nudge-back;
}

@keyframes crossing-arrow-nudge {
  0%, 62%, 100% { translate:0 0; opacity:.38; }
  28% { translate:0 9px; opacity:1; }
}

@keyframes crossing-arrow-nudge-back {
  0%, 62%, 100% { translate:0 0; opacity:.38; }
  28% { translate:0 -9px; opacity:1; }
}

.crossing-status.has-side-cue .crossing-status-arrow { display:none; }

@media (prefers-reduced-motion: reduce) {
  .crossing-status-arrow i,
  .crossing-bubble.is-ready-chevrons i { animation: none; opacity: .85; }
}

/* The three dots that stand in for a waiting line's ellipsis. Sized and
   coloured off the line itself, so every tone — ink, white, the warm default —
   gets them for free, and they inherit the text shadow as their own glow. */
.crossing-dots {
  display: inline-flex;
  align-items: baseline;
  gap: .16em;
  margin-left: .18em;
  vertical-align: baseline;
}
.crossing-dots i {
  width: .21em; height: .21em;
  border-radius: 50%;
  background: currentColor;
  opacity: .22;
  animation: crossing-dot-pulse 1.32s ease-in-out infinite;
}
.crossing-dots i:nth-child(2) { animation-delay: .18s; }
.crossing-dots i:nth-child(3) { animation-delay: .36s; }
@keyframes crossing-dot-pulse {
  0%, 72%, 100% { opacity: .22; transform: translateY(0); }
  30% { opacity: 1; transform: translateY(-.14em); }
}
@media (prefers-reduced-motion: reduce) {
  .crossing-dots i { animation: none; opacity: .6; }
}

/* ── Reflection edit mode panel (?reflection-edit=1) ──
   Deliberately plain and out of the way: it is a ruler, not part of the room. */
.reflection-edit-panel {
  position: fixed; left: 12px; top: 12px; z-index: 999;
  max-width: 300px; padding: 10px 12px;
  font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
  color: #f2ead6; background: #0b1220f2; border: 1px solid #f2ead63d; border-radius: 8px;
  box-shadow: 0 10px 28px #0009;
}
.reflection-edit-panel strong { display: block; margin-bottom: 6px; color: #fff; }
.reflection-edit-panel b { color: #ffd892; }
.reflection-edit-panel .reflection-edit-part { display: block; margin: 8px 0 4px; color: #ffd892; }
.reflection-edit-panel textarea {
  width: 100%; margin: 0 0 6px; padding: 6px; resize: vertical;
  font: 11px/1.4 ui-monospace, monospace; color: #eef; background: #060b16; border: 1px solid #ffffff26; border-radius: 5px;
}
.reflection-edit-panel button {
  margin: 0 6px 0 0; padding: 4px 9px; cursor: pointer;
  font: 11px/1.4 inherit; color: #0b1220; background: #ffd892; border: 0; border-radius: 5px;
}

/* ── Edit mode: make the tunable layers grabbable ──
   Every .layer is pointer-events:none in the finished scene, which is right --
   nothing in the plate should eat a click. That also meant the tuner could
   never be handed the spindle or the arm: the press landed on the stage and
   was read as "the record". Only while editing, the groups answer for
   themselves, and a dashed hairline shows what can be grabbed. */
.reflection-scene.is-rf-edit .spindle,
.reflection-scene.is-rf-edit .tonearm,
.reflection-scene.is-rf-edit .sunny,
.reflection-scene.is-rf-edit .bag,
.reflection-scene.is-rf-edit .spirit-field,
.reflection-scene.is-rf-edit .record-projection {
  pointer-events: auto;
  cursor: grab;
}
.reflection-scene.is-rf-edit .spindle,
.reflection-scene.is-rf-edit .spirit-field {
  outline: 1px dashed #ffd89266;
  outline-offset: 3px;
}
