From 56036216f83c1fad3093f649993b2f146084f9bb Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:38:10 -0500 Subject: [PATCH] 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. --- src/App.svelte | 11 +------ src/components/PreviewOverlay.svelte | 44 ++++++++++++++++------------ 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index e6c36cb..e597773 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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); - }
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} {/if} -
+
{#each visibleCards as { item, index } (item.sessionKey)} {@const avail = availabilityOf(item)}
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; }