fix(showcase): reveal each tile only once its image is fully decoded
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m33s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m23s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 07:19:44 -04:00
parent ae569c0f9a
commit 3a4270e6be
4 changed files with 85 additions and 23 deletions
@@ -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 {
+30 -9
View File
@@ -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
+21
View File
@@ -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)
})
}
+25
View File
@@ -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 <img> 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()