From 3a4270e6bec0a20e4988fda9749f31ce09d36272 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:19:44 -0400 Subject: [PATCH] fix(showcase): reveal each tile only once its image is fully decoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffered cascade revealed tiles on an 80ms timer regardless of image load, so the flip-in animation played on a gray placeholder and the thumbnail popped in afterward. Worse, MasonryGrid ALSO applied a per-index animation-delay (index×70ms) that compounded on top of the insert cadence, so the cascade visibly dragged and desynced as it grew. Now the producer preloads each queued thumbnail (decode pipelined ahead) and the consumer awaits that decode before pushing the item — every tile animates in fully loaded, strictly one at a time. Drop the compounding CSS stagger; the store's one-item-at-a-time push is the sole pacer, so each tile animates the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/discovery/MasonryGrid.vue | 23 +++++------ frontend/src/stores/showcase.js | 39 ++++++++++++++----- frontend/src/utils/preloadImage.js | 21 ++++++++++ frontend/test/showcase.spec.js | 25 ++++++++++++ 4 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 frontend/src/utils/preloadImage.js diff --git a/frontend/src/components/discovery/MasonryGrid.vue b/frontend/src/components/discovery/MasonryGrid.vue index 436b8e9..475482e 100644 --- a/frontend/src/components/discovery/MasonryGrid.vue +++ b/frontend/src/components/discovery/MasonryGrid.vue @@ -6,7 +6,6 @@ v-for="item in col" :key="item.id" class="fc-masonry__item" :class="{ 'fc-masonry__item--anim': shouldAnimate(item) }" - :style="itemStyle(item)" type="button" @click="$emit('open', item.id)" > @@ -66,12 +65,6 @@ function shouldAnimate(item) { return idx !== undefined && idx >= props.animateFromIndex } -function itemStyle(item) { - if (!shouldAnimate(item)) return {} - const idx = idxById.value.get(item.id) - props.animateFromIndex - return { '--stagger-index': idx } -} - function aspectStyle(item) { const w = Number(item.width) const h = Number(item.height) @@ -117,12 +110,15 @@ useInfiniteScroll(sentinelEl, () => { .fc-masonry__end { text-align: center; padding: 32px 0; } /* Cascade entry: each tile flips up out of a backward tilt and settles - into place, one at a time — more pronounced than a plain fade so the - showcase reads as an "experience" (operator-flagged 2026-05-28). The - `both` fill holds the hidden/tilted 0% state until each tile's staggered - turn; the cubic-bezier overshoots slightly past flat then settles. - Honors prefers-reduced-motion. Tunables: tilt (-28deg), stagger (70ms), - duration (0.6s). */ + into place — more pronounced than a plain fade so the showcase reads as an + "experience" (operator-flagged 2026-05-28). The reveal is paced entirely by + the store, which pushes one fully-decoded item at a time (showcase.js); each + tile therefore animates the instant it mounts, with NO per-index CSS delay — + the old `animation-delay: index×70ms` compounded on top of the insert + cadence and made the cascade drag and desync as it grew (operator-flagged + 2026-06-04). The `both` fill holds the hidden/tilted 0% state until mount; + the cubic-bezier overshoots slightly past flat then settles. Honors + prefers-reduced-motion. Tunables: tilt (-28deg), duration (0.6s). */ @keyframes fc-masonry-item-in { 0% { opacity: 0; @@ -138,7 +134,6 @@ useInfiniteScroll(sentinelEl, () => { transform-origin: center top; backface-visibility: hidden; animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both; - animation-delay: calc(var(--stagger-index, 0) * 70ms); } @media (prefers-reduced-motion: reduce) { .fc-masonry__item--anim { diff --git a/frontend/src/stores/showcase.js b/frontend/src/stores/showcase.js index 4f6847e..23ed096 100644 --- a/frontend/src/stores/showcase.js +++ b/frontend/src/stores/showcase.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { preloadImage } from '../utils/preloadImage.js' // Buffered producer/consumer so the cascade cadence is decoupled from fetch // latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch @@ -9,15 +10,21 @@ import { useApi } from '../composables/useApi.js' // sometimes-cold blocks; RTT jitter) the animation starved and the view // "burped" out a clump of images. Now: // - PRODUCER (_fill): races ahead fetching batches into `queue` up to a -// target depth, refilling whenever the queue dips below BUFFER_MIN. -// - CONSUMER (_drain): pops ONE item every CADENCE_MS, independent of when -// fetches land. It only waits if the buffer genuinely starves. -// As long as fetches keep up on average (a 3-row fetch is usually well under -// the BUFFER_MIN×CADENCE runway), images appear at a perfectly even pace. -// The very first image still waits on the first fetch (a cold TABLESAMPLE is a -// separate, query-side concern); everything after it is buffer-smoothed. +// target depth, refilling whenever the queue dips below BUFFER_MIN. It +// also kicks off the image PRELOAD for each queued item so decoding +// pipelines ahead of the reveal. +// - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully +// decoded, then reveals it — at most one per CADENCE_MS. Because the +// producer preloads ahead, the decode-wait is usually already satisfied, +// so the reveal stays evenly paced without idling the network. +// The decode-gate is what makes the showcase's signature cascade land each +// tile fully-loaded (the flip-in animates a real image, never a gray +// placeholder); the single-item reveal keeps it strictly one-at-a-time +// (operator-flagged 2026-06-04). The very first image still waits on the first +// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern); +// everything after it is buffer-smoothed. const PAGE = 3 -const CADENCE_MS = 80 // ≈ the MasonryGrid 70ms stagger animation +const CADENCE_MS = 80 // floor between fully-loaded reveals const PRIME = 6 // items buffered before the drain starts const BUFFER_TARGET = 30 // producer tops the queue up to this const BUFFER_MIN = 12 // ...and refills once the queue dips below this @@ -42,12 +49,21 @@ export const useShowcaseStore = defineStore('showcase', () => { // Internal buffer (not reactive — the consumer is what feeds the UI via // images.value). let queue = [] + // id -> Promise that settles when the thumbnail is paint-ready. Started by + // the producer so decoding runs ahead of the reveal; awaited by the consumer + // so no tile is shown before its image is loaded. + let _preloads = new Map() // Sequence token: shuffle / re-mount bumps it so in-flight producers and // the drain bail instead of interleaving two runs into one images list. let _seq = 0 let _filling = false let _draining = false + function _preload(item) { + if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url)) + return _preloads.get(item.id) + } + // One batch, retried while the random sample comes back all-duplicates. // Returns the fresh items, or null when the API is genuinely empty. async function _fetchFresh(mySeq) { @@ -79,6 +95,7 @@ export const useShowcaseStore = defineStore('showcase', () => { if (mySeq !== _seq) return if (batch === null) { exhausted.value = true; return } queue.push(...batch) + batch.forEach(_preload) // pipeline decoding ahead of the reveal } } finally { _filling = false @@ -104,7 +121,10 @@ export const useShowcaseStore = defineStore('showcase', () => { } if (mySeq !== _seq) return if (queue.length === 0) return // exhausted + empty - images.value.push(queue.shift()) + const item = queue.shift() + await _preload(item) // reveal only once the thumbnail is fully decoded + if (mySeq !== _seq) return + images.value.push(item) await _sleep(CADENCE_MS) } } finally { @@ -120,6 +140,7 @@ export const useShowcaseStore = defineStore('showcase', () => { const mySeq = _seq images.value = [] queue = [] + _preloads = new Map() seen.clear() exhausted.value = false error.value = null diff --git a/frontend/src/utils/preloadImage.js b/frontend/src/utils/preloadImage.js new file mode 100644 index 0000000..dba84fa --- /dev/null +++ b/frontend/src/utils/preloadImage.js @@ -0,0 +1,21 @@ +// Resolve once the image at `url` is fully loaded AND decoded (paint-ready), +// or after `timeoutMs` as a safety net so a broken/slow image never stalls a +// cascade. Never rejects — the caller only cares that it's safe to reveal. +// +// Used by the showcase cascade to gate each tile's entry animation on the +// thumbnail actually being ready, so the flip-in plays on a real image rather +// than on a gray placeholder (operator-flagged 2026-06-04). +export function preloadImage (url, timeoutMs = 4000) { + return new Promise((resolve) => { + let done = false + const finish = () => { if (!done) { done = true; resolve() } } + const img = new Image() + img.onload = finish + img.onerror = finish + img.src = url + // decode() resolves at paint-ready (a beat after onload); prefer it when + // available, but onload/onerror/timeout all still settle the promise. + if (typeof img.decode === 'function') img.decode().then(finish).catch(() => {}) + setTimeout(finish, timeoutMs) + }) +} diff --git a/frontend/test/showcase.spec.js b/frontend/test/showcase.spec.js index 961d2ee..9fbbbcb 100644 --- a/frontend/test/showcase.spec.js +++ b/frontend/test/showcase.spec.js @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' + +// Mock the decode-gate so the cascade is paced purely by the store's cadence +// in tests (no real load events in happy-dom). Individual tests can +// override per-call to exercise the gating itself. +vi.mock('../src/utils/preloadImage.js', () => ({ + preloadImage: vi.fn(() => Promise.resolve()), +})) +import { preloadImage } from '../src/utils/preloadImage.js' import { useShowcaseStore } from '../src/stores/showcase.js' let nextId @@ -25,6 +33,8 @@ describe('showcase store: buffered cascade', () => { beforeEach(() => { setActivePinia(createPinia()) nextId = 1 + preloadImage.mockReset() + preloadImage.mockImplementation(() => Promise.resolve()) vi.useFakeTimers() }) afterEach(() => { @@ -60,6 +70,21 @@ describe('showcase store: buffered cascade', () => { expect(s.images.length).toBe(60) }) + it('does not reveal a tile until its image has decoded', async () => { + stubShowcase() + let release + // The first tile's preload hangs until we release it; all others resolve. + preloadImage.mockImplementationOnce(() => new Promise((r) => { release = r })) + const s = useShowcaseStore() + const p = s.loadInitial() + await vi.advanceTimersByTimeAsync(1000) + expect(s.images.length).toBe(0) // gated on the un-decoded first image + release() + await vi.advanceTimersByTimeAsync(6000) + await p + expect(s.images.length).toBe(60) // cascade proceeds once it decodes + }) + it('flags an empty library without starting the cascade', async () => { stubShowcase({ empty: true }) const s = useShowcaseStore()