dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf #62
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user