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:
1 parent
e6252b9bb3
commit
56036216f8
2 files changed
+27
-28
No files matched your search
+1
-10
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user