forked from Keelrleek/SoulStream
feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP.
This commit is contained in:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
@@ -0,0 +1,193 @@
|
||||
<script>
|
||||
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
|
||||
// system + process-tree CPU/GPU/RAM, network rates, the active track's
|
||||
// download progress/speed and loudness-normalization state, renderer
|
||||
// FPS, and the preview session when one is open. Polls the backend only
|
||||
// while visible; costs nothing when hidden.
|
||||
import * as api from "../lib/api.js";
|
||||
import { player } from "../lib/state.svelte.js";
|
||||
import { previewUi } from "../lib/preview/previewSession.svelte.js";
|
||||
|
||||
let visible = $state(false);
|
||||
let stats = $state(null);
|
||||
let fps = $state(0);
|
||||
let dlSpeed = $state(0); // bytes/sec of the CURRENT track's stream
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
|
||||
e.preventDefault();
|
||||
visible = !visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll + FPS only while visible.
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
let lastReceived = 0;
|
||||
let lastAt = performance.now();
|
||||
const tick = async () => {
|
||||
try {
|
||||
stats = await api.debugStats();
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
// Download speed from the BACKEND's own progress handle — the
|
||||
// player store's mirror is window-activity-gated and can lag.
|
||||
const now = performance.now();
|
||||
const cur = stats?.dlReceived ?? 0;
|
||||
const dt = (now - lastAt) / 1000;
|
||||
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
|
||||
lastReceived = cur;
|
||||
lastAt = now;
|
||||
};
|
||||
void tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
|
||||
let frames = 0;
|
||||
let fpsWindow = performance.now();
|
||||
let raf = requestAnimationFrame(function loop() {
|
||||
frames += 1;
|
||||
const now = performance.now();
|
||||
if (now - fpsWindow >= 500) {
|
||||
fps = Math.round((frames * 1000) / (now - fpsWindow));
|
||||
frames = 0;
|
||||
fpsWindow = now;
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
});
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
});
|
||||
|
||||
const mb = (bytes) => (bytes / 1048576).toFixed(bytes >= 10485760 ? 0 : 1);
|
||||
const gb = (bytes) => (bytes / 1073741824).toFixed(1);
|
||||
function rate(bps) {
|
||||
if (bps >= 1048576) return `${(bps / 1048576).toFixed(1)} MB/s`;
|
||||
return `${(bps / 1024).toFixed(0)} KB/s`;
|
||||
}
|
||||
function fmtUptime(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m${String(secs % 60).padStart(2, "0")}s`;
|
||||
}
|
||||
const dlTotal = $derived(stats?.dlTotal ?? 0);
|
||||
const dlDone = $derived(stats?.dlReceived ?? 0);
|
||||
const loud = $derived(stats?.loudness ?? null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if visible}
|
||||
<aside class="debug-overlay" aria-hidden="true">
|
||||
<div class="hdr">DEBUG <span class="dim">Ctrl+Shift+D</span></div>
|
||||
{#if stats}
|
||||
<div class="sec">
|
||||
<div><b>CPU</b> app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%</div>
|
||||
<div><b>GPU</b> {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}</div>
|
||||
<div><b>RAM</b> ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB</div>
|
||||
<div class="dim">system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs</div>
|
||||
<div><b>NET</b> ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}</div>
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div><b>FPS</b> {fps}</div>
|
||||
<div><b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}</div>
|
||||
{#if dlTotal > 0}
|
||||
<div>
|
||||
<b>DL</b>
|
||||
{mb(dlDone)} / {mb(dlTotal)} MB ({((dlDone / dlTotal) * 100).toFixed(0)}%)
|
||||
{#if dlDone < dlTotal}· {rate(dlSpeed)}{:else}· done{/if}
|
||||
</div>
|
||||
<div class="bar"><i style="width:{Math.min(100, (dlDone / dlTotal) * 100)}%"></i></div>
|
||||
{:else}
|
||||
<div class="dim">DL — no active stream</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div>
|
||||
<b>NORM</b>
|
||||
{#if !stats.normalizationEnabled}off{:else if loud}
|
||||
{loud.lufs != null ? `${loud.lufs.toFixed(1)} LUFS` : "measuring…"}
|
||||
→ {loud.gainDb >= 0 ? "+" : ""}{loud.gainDb.toFixed(1)} dB
|
||||
<span class="dim">(target {stats.targetLufs})</span>
|
||||
{:else}on · idle{/if}
|
||||
</div>
|
||||
{#if stats.normalizationEnabled && loud}
|
||||
<div class="dim">
|
||||
peak {loud.peak.toFixed(2)}
|
||||
· {loud.cached ? "cache hit" : loud.analyzing ? "analyzing" : "measured this play"}
|
||||
</div>
|
||||
{/if}
|
||||
{#if previewUi.open}
|
||||
<div>
|
||||
<b>PREVIEW</b>
|
||||
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
|
||||
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dim ft">up {fmtUptime(stats.uptimeSecs)}</div>
|
||||
{:else}
|
||||
<div class="dim">waiting for backend…</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.debug-overlay {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
right: 12px;
|
||||
z-index: 2147483000;
|
||||
pointer-events: none;
|
||||
min-width: 300px;
|
||||
max-width: 380px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(8, 10, 14, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #d7e0ea;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.hdr {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #7fd0ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sec {
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
b {
|
||||
color: #9fc1e0;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
min-width: 46px;
|
||||
}
|
||||
.dim {
|
||||
color: rgba(215, 224, 234, 0.55);
|
||||
}
|
||||
.ft {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
margin: 2px 0 1px;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #7fd0ff;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,8 @@
|
||||
// R1b capsule, T3 directional metadata swaps, folded control row with
|
||||
// the P2 ghost pill, F3 footer.
|
||||
import { tick, untrack } from "svelte";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import {
|
||||
previewUi,
|
||||
previewCommands,
|
||||
@@ -31,6 +33,16 @@
|
||||
const playing = $derived(st?.phase === PHASE.PLAYING);
|
||||
const ended = $derived(st?.phase === PHASE.ENDED);
|
||||
const failed = $derived(st?.phase === PHASE.ITEM_FAILED);
|
||||
// The play button shows INTENT, not the transport's instantaneous phase:
|
||||
// during a step the session passes through resolving/buffering for a few
|
||||
// hundred ms, and flipping the glyph there reads as a stutter (user
|
||||
// report 2026-07-23). togglePlay() parks from those phases, so the pause
|
||||
// glyph is honest — clicking it stops the audio either way.
|
||||
const wantsPlay = $derived(
|
||||
playing ||
|
||||
(st?.desiredPlaying &&
|
||||
(st?.phase === PHASE.RESOLVING_MEDIA || st?.phase === PHASE.BUFFERING))
|
||||
);
|
||||
const playable = $derived(st ? playableManifestIndices(st) : []);
|
||||
const cursor = $derived(st ? playableCursor(st) : null);
|
||||
const unavailableCount = $derived(items.length - playable.length);
|
||||
@@ -399,6 +411,16 @@
|
||||
const posLabel = $derived(fmtMs(st?.positionMs ?? 0) || "0:00");
|
||||
const clipLabel = $derived(fmtMs(st?.clipDurationMs ?? 30_000) || "0:30");
|
||||
|
||||
// Shared by the transport buttons and the reel-side arrows.
|
||||
const prevDisabled = $derived(
|
||||
cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0
|
||||
);
|
||||
const nextDisabled = $derived(
|
||||
cursor == null
|
||||
? activeIndex != null && !playable.some((i) => i > activeIndex)
|
||||
: cursor >= playable.length - 1
|
||||
);
|
||||
|
||||
async function onPlayFull() {
|
||||
if (!previewUi.playFull || !activeItem) return;
|
||||
const starter = previewUi.playFull;
|
||||
@@ -410,7 +432,15 @@
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if previewUi.open && st}
|
||||
<div class="scrim" onclick={() => closePreview()} aria-hidden="true"></div>
|
||||
<!-- Scale & fade (user pick 2026-07-23, variant A of the motion mockup):
|
||||
240 ms in from 96%, 190 ms out to 97%, scrim fading alongside. -->
|
||||
<div
|
||||
class="scrim"
|
||||
onclick={() => closePreview()}
|
||||
aria-hidden="true"
|
||||
in:fade={{ duration: 240 }}
|
||||
out:fade={{ duration: 190 }}
|
||||
></div>
|
||||
<div
|
||||
class="modal"
|
||||
class:is-playing={playing}
|
||||
@@ -419,6 +449,8 @@
|
||||
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
|
||||
style="--pv-accent: {pvAccent}; --pv-accent-hover: {pvHover};"
|
||||
bind:this={dialogEl}
|
||||
in:scale={{ duration: 240, start: 0.96, easing: cubicOut }}
|
||||
out:scale={{ duration: 190, start: 0.97 }}
|
||||
>
|
||||
<div class="bg" aria-hidden="true">
|
||||
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
|
||||
@@ -490,18 +522,18 @@
|
||||
class="m-step"
|
||||
title="Previous preview (↑)"
|
||||
aria-label="Previous preview"
|
||||
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0}
|
||||
disabled={prevDisabled}
|
||||
onclick={() => step(-1)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2.2v12H6zm3.8 6l8.2 6V6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="m-play"
|
||||
title={playing ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
|
||||
aria-label={playing ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
|
||||
title={wantsPlay ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
|
||||
aria-label={wantsPlay ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
|
||||
onclick={() => previewCommands.togglePlay()}
|
||||
>
|
||||
{#if playing}
|
||||
{#if wantsPlay}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
|
||||
{:else if ended}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
|
||||
@@ -513,7 +545,7 @@
|
||||
class="m-step"
|
||||
title="Next preview (↓)"
|
||||
aria-label="Next preview"
|
||||
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i > activeIndex) : cursor >= playable.length - 1}
|
||||
disabled={nextDisabled}
|
||||
onclick={() => step(1)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.8 6H18v12h-2.2zM6 6l8.2 6L6 18z" /></svg>
|
||||
@@ -574,6 +606,26 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="reel-arrows">
|
||||
<button
|
||||
class="reel-arrow"
|
||||
title="Previous preview (↑)"
|
||||
aria-label="Previous preview"
|
||||
disabled={prevDisabled}
|
||||
onclick={() => step(-1)}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14.5l6-6 6 6" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="reel-arrow"
|
||||
title="Next preview (↓)"
|
||||
aria-label="Next preview"
|
||||
disabled={nextDisabled}
|
||||
onclick={() => step(1)}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9.5l6 6 6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -589,7 +641,13 @@
|
||||
{/each}
|
||||
</span>
|
||||
<span class="m-pos">
|
||||
{cursor != null ? `${cursor + 1} of ${playable.length}` : `unavailable · ${(activeIndex ?? 0) + 1} of ${items.length}`}
|
||||
{#if cursor != null}
|
||||
<!-- Reserve the widest digit count so stepping 9→10
|
||||
never widens the label and reflows the flex rail. -->
|
||||
<span class="pos-n" style="min-width: {String(playable.length).length}ch">{cursor + 1}</span> of {playable.length}
|
||||
{:else}
|
||||
unavailable · {(activeIndex ?? 0) + 1} of {items.length}
|
||||
{/if}
|
||||
{#if unavailableCount > 0} · {unavailableCount} unavailable{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -604,14 +662,31 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
/* Spotify-style: the page behind darkens clearly and blurs a touch,
|
||||
so the panel reads as a floating modal rather than a full takeover. */
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
/* A centred, window-relative panel — not an inset-26 near-fullscreen
|
||||
sheet. Capped so it never dominates a large monitor while the two
|
||||
columns still keep their room on smaller windows. */
|
||||
position: fixed;
|
||||
inset: 26px;
|
||||
/* Safe frame: the modal centers (margin auto) inside a fixed inset
|
||||
box whose 48px top ALWAYS clears the 30px app titlebar — the modal
|
||||
can never cover it, whatever the window size — with matching
|
||||
gutters on the sides and bottom. The width/height caps keep it a
|
||||
real modal on a 4K monitor (1660×1000) while the calc() terms
|
||||
guarantee it never outgrows the frame on small windows. */
|
||||
inset: 48px;
|
||||
margin: auto;
|
||||
width: min(1660px, 90vw, calc(100vw - 96px));
|
||||
height: min(1000px, calc(100vh - 96px));
|
||||
/* vw-based headline: small windows sit at the 38px floor and only
|
||||
wide screens grow it. */
|
||||
--pv-title: clamp(38px, 2.4vw, 52px);
|
||||
z-index: 61;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
@@ -727,7 +802,7 @@
|
||||
/* -------------------------------------------------------------- body */
|
||||
.m-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 500px) auto;
|
||||
grid-template-columns: minmax(320px, 680px) auto;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
padding: 0 20px;
|
||||
@@ -743,13 +818,14 @@
|
||||
/* Fixed two-line title slot, BOTTOM-aligned: a one-line title sits against
|
||||
the artist row instead of floating a line above it. */
|
||||
.title-slot {
|
||||
height: 82px;
|
||||
/* Two lines of the (now responsive) title at 1.08 line-height. */
|
||||
height: calc(var(--pv-title, 38px) * 1.08 * 2);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-title {
|
||||
font-size: 38px;
|
||||
font-size: var(--pv-title, 38px);
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.8px;
|
||||
@@ -797,7 +873,7 @@
|
||||
gap: 11px;
|
||||
margin-top: 28px;
|
||||
/* Spans the same width as the control row beneath it. */
|
||||
max-width: 472px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.m-wave {
|
||||
flex: 1;
|
||||
@@ -830,7 +906,10 @@
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.m-play {
|
||||
/* Scoped under .m-transport so it outranks the `.m-transport button`
|
||||
reset (0,1,1) that was blanking the accent fill and the black glyph —
|
||||
the mockup only had a plain `button{}` reset, which lost to `.m-play`. */
|
||||
.m-transport .m-play {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
@@ -841,7 +920,7 @@
|
||||
transition: background 0.35s ease;
|
||||
flex: none;
|
||||
}
|
||||
.m-play:hover {
|
||||
.m-transport .m-play:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.m-step {
|
||||
@@ -917,7 +996,11 @@
|
||||
}
|
||||
.reel {
|
||||
/* Single source of geometry: everything below is calc() off these. */
|
||||
--cover: clamp(190px, 34vh, 340px);
|
||||
/* max(34vh, 22vw) so wide screens (4K) grow the cover via the vw term
|
||||
while smaller windows stay on 34vh exactly as before — 22vw only
|
||||
overtakes 34vh once the viewport is very wide, so the 4:3 window is
|
||||
untouched and the 4K cover jumps from 340 up to the 540 cap. */
|
||||
--cover: clamp(190px, max(34vh, 22vw), 540px);
|
||||
--gap: 30px;
|
||||
--stride: calc(var(--cover) + var(--gap));
|
||||
--bleed-room: 70px;
|
||||
@@ -928,6 +1011,43 @@
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
||||
mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
||||
}
|
||||
/* Manual step chevrons beside the art (user request 2026-07-23): bare
|
||||
glyphs like the reference, no chrome until hover. They sit in the
|
||||
reel's 70px bleed gutter — negative margin tucks them against the
|
||||
cover's edge without widening the column. */
|
||||
.reel-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 26px;
|
||||
margin-left: -30px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.reel-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.reel-arrow:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.reel-arrow:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.reel-arrow:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reel-track {
|
||||
/* A fixed anchor at the reel centre — it never moves. Cards position
|
||||
themselves relative to it at (index − activeIndex) × stride and
|
||||
@@ -1134,6 +1254,11 @@
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Fixed box height: the active segment animates 3↔5px, so without a
|
||||
pinned height the rail's box shrank mid-step and the centred rail
|
||||
bounced ~0.5px vertically on every track change. Now the segments
|
||||
grow inside a constant-height box and nothing reflows. */
|
||||
height: 5px;
|
||||
}
|
||||
.rail i {
|
||||
flex: 1;
|
||||
@@ -1149,6 +1274,10 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: none;
|
||||
}
|
||||
.pos-n {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sr-announce {
|
||||
position: absolute;
|
||||
|
||||
Reference in new issue
Block a user