dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf #62

Merged
bvandeusen merged 14 commits from dev into main 2026-06-04 08:27:47 -04:00
4 changed files with 85 additions and 23 deletions
Showing only changes of commit 3a4270e6be - Show all commits
@@ -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()