Phase 2C: fix reel centering via window-relative card positioning

The transitioned track translateY collapsed to 0 in Chromium (percent-
translated zero-height track), leaving the active cover one stride low
per step. The track is now a fixed anchor at the reel centre; each card
positions itself at (index - activeIndex) * stride and animates its own
small `top`, so no coordinate ever exceeds +/-RENDER_RADIUS * stride.

Verified with the transition ON: centre offset 0 at every index in the
12- and 500-item fixtures, 5 cards in the DOM at scale, wheel gates,
dialog semantics, Tab containment, focus restore, and polite announce
all pass. Dev window.__preview*/__ui handles removed from App.svelte.
This commit is contained in:
KeelrLeek committed 2026-07-22 19:38:10 -05:00
1 parent e6252b9bb3
commit 56036216f8
2 files changed
+27 -28

No files matched your search

+1 -10
View File
@@ -6,7 +6,7 @@
import ArtworkViewer from "./components/ArtworkViewer.svelte";
import LoginModal from "./components/LoginModal.svelte";
import PreviewOverlay from "./components/PreviewOverlay.svelte";
import { previewUi, openPreviewFixture } from "./lib/preview/previewSession.svelte.js";
import { previewUi } from "./lib/preview/previewSession.svelte.js";
import Home from "./views/Home.svelte";
import Search from "./views/Search.svelte";
import Library from "./views/Library.svelte";
@@ -43,15 +43,6 @@
refreshLibrary();
refreshPlaylists();
// Dev-only hook for the Phase 2 exit-gate check (500-item fixture in a
// plain browser) and for driving the modal without a Tauri backend.
if (import.meta.env.DEV && typeof window !== "undefined") {
window.__previewFixture = (n = 12) => openPreviewFixture(n);
window.__previewUi = previewUi; // debug: the app's own store instance
window.__ui = ui; // debug: known-good reactive store, same instance
window.__togglePv = () => (previewUi.open = !previewUi.open);
window.__toggleNp = () => (ui.nowPlaying = !ui.nowPlaying);
}
</script>
<div
+26 -18
View File
@@ -84,14 +84,10 @@
// ------------------------------------------------------------- reel ----
// Virtualized: only a window of cards around the active index renders,
// positioned arithmetically (uniform stride), so a 500-item manifest
// costs the same as a 10-item one — the Phase 2 exit gate.
// All reel geometry is CSS-computed from a single `--cover` clamp and a
// `--gap`, so JS never carries a pixel size that could disagree with the
// rendered box (the bug that left stepped cards off-centre). JS supplies
// only integer indices; CSS turns them into pixels. Virtualization: only
// a window of cards around the active index is in the DOM, positioned by
// absolute index, so a 500-item manifest costs the same as a 10-item one.
// positioned WINDOW-RELATIVE (offset from the active index, not from
// manifest index 0), so a 500-item manifest costs the same as a 10-item
// one — the Phase 2 exit gate — and no coordinate ever exceeds
// ±RENDER_RADIUS × stride.
const RENDER_RADIUS = 4;
const GAP = 30;
const windowStart = $derived(Math.max(0, (activeIndex ?? 0) - RENDER_RADIUS));
@@ -107,7 +103,7 @@
// predict), measured off the live reel. CSS calc with an inherited
// unitless var proved unreliable here, so JS owns the arithmetic — but
// from the real pixel value, which is what made the earlier viewport-guess
// version drift. Cards and the track slide share this one number.
// version drift.
let reelEl = $state(null);
let stride = $state(220);
function measureStride() {
@@ -124,7 +120,17 @@
ro.observe(reelEl);
return () => ro.disconnect();
});
const trackOffset = $derived((activeIndex ?? 0) * stride);
// Window-relative positioning: each card sits at (index − activeIndex) ×
// stride from the reel centre, so the ACTIVE card is always exactly at the
// centre with NO track translate. The slide is each card's own small `top`
// transitioning; coordinates never exceed ±RENDER_RADIUS × stride, which
// kills two bugs at once — the giant absolute tops at index 500, and the
// transitioned track translateY that Chromium landed at 0 (a transition on
// a percent-translated zero-height track dropped the Y component; see
// HANDOFF-PHASE2C.md for the measurements).
function cardOffset(index) {
return (index - (activeIndex ?? 0)) * stride;
}
function availabilityOf(item) {
return st?.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
@@ -534,7 +540,7 @@
{#if activeItem?.artwork}
<img class="cover-glow" src={activeItem.artwork} alt="" aria-hidden="true" />
{/if}
<div class="reel-track" style="transform: translateX(-50%) translateY({-trackOffset}px);">
<div class="reel-track">
{#each visibleCards as { item, index } (item.sessionKey)}
{@const avail = availabilityOf(item)}
<div
@@ -542,7 +548,7 @@
class:active={index === activeIndex}
class:gone={avail !== "available"}
class:hit={index === activeIndex && bumpKey > 0}
style="top: {index * stride}px; --idle-scale: {index === activeIndex ? 1 : 0.74};"
style="top: {cardOffset(index)}px; --idle-scale: {index === activeIndex ? 1 : 0.74};"
onclick={() => avail === "available" && activate(item, index)}
role={index === activeIndex ? undefined : "button"}
aria-hidden={index !== activeIndex}
@@ -923,16 +929,16 @@
mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
}
.reel-track {
/* A fixed anchor at the reel centre — it never moves. Cards position
themselves relative to it at (index − activeIndex) × stride and
animate their own `top`; a transitioned translateY on this track is
exactly what Chromium collapsed to 0 (see the file-top comment). */
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
width: var(--cover);
will-change: transform;
/* translateY is set inline from JS (measured stride × active index);
translateX(-50%) here centres the track's own width. */
transform: translateX(-50%);
transition: transform 340ms cubic-bezier(0.26, 1.28, 0.6, 1);
}
/* Artwork bloom (B2): a blurred copy of the active cover, box-feathered
@@ -973,7 +979,9 @@
.card {
position: absolute;
left: 0;
/* top set inline from JS (index × measured stride) */
/* top set inline from JS ((index − activeIndex) × measured stride);
transitioning it is what produces the slide — the track itself
never moves. */
width: var(--cover);
height: var(--cover);
border-radius: 8px;
@@ -986,6 +994,7 @@
box, so idle covers at 74% cannot perturb centring. */
transform: translateY(-50%) scale(var(--idle-scale, 0.74));
transition:
top 340ms cubic-bezier(0.26, 1.28, 0.6, 1),
transform 340ms cubic-bezier(0.26, 1.28, 0.6, 1),
opacity 0.3s ease,
filter 0.3s ease,
@@ -1175,7 +1184,6 @@
/* --------------------------------------------------- reduced motion --- */
@media (prefers-reduced-motion: reduce) {
.reel-track { transition: none; }
.card { transition: opacity 0.3s ease, filter 0.3s ease; }
.card.active.hit { animation: none; }
.modal.is-playing .cover-glow { animation: none; }