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 ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||||
import LoginModal from "./components/LoginModal.svelte";
|
import LoginModal from "./components/LoginModal.svelte";
|
||||||
import PreviewOverlay from "./components/PreviewOverlay.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 Home from "./views/Home.svelte";
|
||||||
import Search from "./views/Search.svelte";
|
import Search from "./views/Search.svelte";
|
||||||
import Library from "./views/Library.svelte";
|
import Library from "./views/Library.svelte";
|
||||||
@@ -43,15 +43,6 @@
|
|||||||
refreshLibrary();
|
refreshLibrary();
|
||||||
refreshPlaylists();
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -84,14 +84,10 @@
|
|||||||
|
|
||||||
// ------------------------------------------------------------- reel ----
|
// ------------------------------------------------------------- reel ----
|
||||||
// Virtualized: only a window of cards around the active index renders,
|
// Virtualized: only a window of cards around the active index renders,
|
||||||
// positioned arithmetically (uniform stride), so a 500-item manifest
|
// positioned WINDOW-RELATIVE (offset from the active index, not from
|
||||||
// costs the same as a 10-item one — the Phase 2 exit gate.
|
// manifest index 0), so a 500-item manifest costs the same as a 10-item
|
||||||
// All reel geometry is CSS-computed from a single `--cover` clamp and a
|
// one — the Phase 2 exit gate — and no coordinate ever exceeds
|
||||||
// `--gap`, so JS never carries a pixel size that could disagree with the
|
// ±RENDER_RADIUS × stride.
|
||||||
// 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.
|
|
||||||
const RENDER_RADIUS = 4;
|
const RENDER_RADIUS = 4;
|
||||||
const GAP = 30;
|
const GAP = 30;
|
||||||
const windowStart = $derived(Math.max(0, (activeIndex ?? 0) - RENDER_RADIUS));
|
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
|
// predict), measured off the live reel. CSS calc with an inherited
|
||||||
// unitless var proved unreliable here, so JS owns the arithmetic — but
|
// unitless var proved unreliable here, so JS owns the arithmetic — but
|
||||||
// from the real pixel value, which is what made the earlier viewport-guess
|
// 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 reelEl = $state(null);
|
||||||
let stride = $state(220);
|
let stride = $state(220);
|
||||||
function measureStride() {
|
function measureStride() {
|
||||||
@@ -124,7 +120,17 @@
|
|||||||
ro.observe(reelEl);
|
ro.observe(reelEl);
|
||||||
return () => ro.disconnect();
|
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) {
|
function availabilityOf(item) {
|
||||||
return st?.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
return st?.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
||||||
@@ -534,7 +540,7 @@
|
|||||||
{#if activeItem?.artwork}
|
{#if activeItem?.artwork}
|
||||||
<img class="cover-glow" src={activeItem.artwork} alt="" aria-hidden="true" />
|
<img class="cover-glow" src={activeItem.artwork} alt="" aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="reel-track" style="transform: translateX(-50%) translateY({-trackOffset}px);">
|
<div class="reel-track">
|
||||||
{#each visibleCards as { item, index } (item.sessionKey)}
|
{#each visibleCards as { item, index } (item.sessionKey)}
|
||||||
{@const avail = availabilityOf(item)}
|
{@const avail = availabilityOf(item)}
|
||||||
<div
|
<div
|
||||||
@@ -542,7 +548,7 @@
|
|||||||
class:active={index === activeIndex}
|
class:active={index === activeIndex}
|
||||||
class:gone={avail !== "available"}
|
class:gone={avail !== "available"}
|
||||||
class:hit={index === activeIndex && bumpKey > 0}
|
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)}
|
onclick={() => avail === "available" && activate(item, index)}
|
||||||
role={index === activeIndex ? undefined : "button"}
|
role={index === activeIndex ? undefined : "button"}
|
||||||
aria-hidden={index !== activeIndex}
|
aria-hidden={index !== activeIndex}
|
||||||
@@ -923,16 +929,16 @@
|
|||||||
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%);
|
||||||
}
|
}
|
||||||
.reel-track {
|
.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;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: var(--cover);
|
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%);
|
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
|
/* Artwork bloom (B2): a blurred copy of the active cover, box-feathered
|
||||||
@@ -973,7 +979,9 @@
|
|||||||
.card {
|
.card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
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);
|
width: var(--cover);
|
||||||
height: var(--cover);
|
height: var(--cover);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -986,6 +994,7 @@
|
|||||||
box, so idle covers at 74% cannot perturb centring. */
|
box, so idle covers at 74% cannot perturb centring. */
|
||||||
transform: translateY(-50%) scale(var(--idle-scale, 0.74));
|
transform: translateY(-50%) scale(var(--idle-scale, 0.74));
|
||||||
transition:
|
transition:
|
||||||
|
top 340ms cubic-bezier(0.26, 1.28, 0.6, 1),
|
||||||
transform 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,
|
opacity 0.3s ease,
|
||||||
filter 0.3s ease,
|
filter 0.3s ease,
|
||||||
@@ -1175,7 +1184,6 @@
|
|||||||
|
|
||||||
/* --------------------------------------------------- reduced motion --- */
|
/* --------------------------------------------------- reduced motion --- */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reel-track { transition: none; }
|
|
||||||
.card { transition: opacity 0.3s ease, filter 0.3s ease; }
|
.card { transition: opacity 0.3s ease, filter 0.3s ease; }
|
||||||
.card.active.hit { animation: none; }
|
.card.active.hit { animation: none; }
|
||||||
.modal.is-playing .cover-glow { animation: none; }
|
.modal.is-playing .cover-glow { animation: none; }
|
||||||
|
|||||||
Reference in new issue
Block a user