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()