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"
|
v-for="item in col" :key="item.id"
|
||||||
class="fc-masonry__item"
|
class="fc-masonry__item"
|
||||||
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
||||||
:style="itemStyle(item)"
|
|
||||||
type="button"
|
type="button"
|
||||||
@click="$emit('open', item.id)"
|
@click="$emit('open', item.id)"
|
||||||
>
|
>
|
||||||
@@ -66,12 +65,6 @@ function shouldAnimate(item) {
|
|||||||
return idx !== undefined && idx >= props.animateFromIndex
|
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) {
|
function aspectStyle(item) {
|
||||||
const w = Number(item.width)
|
const w = Number(item.width)
|
||||||
const h = Number(item.height)
|
const h = Number(item.height)
|
||||||
@@ -117,12 +110,15 @@ useInfiniteScroll(sentinelEl, () => {
|
|||||||
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
||||||
|
|
||||||
/* Cascade entry: each tile flips up out of a backward tilt and settles
|
/* 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
|
into place — more pronounced than a plain fade so the showcase reads as an
|
||||||
showcase reads as an "experience" (operator-flagged 2026-05-28). The
|
"experience" (operator-flagged 2026-05-28). The reveal is paced entirely by
|
||||||
`both` fill holds the hidden/tilted 0% state until each tile's staggered
|
the store, which pushes one fully-decoded item at a time (showcase.js); each
|
||||||
turn; the cubic-bezier overshoots slightly past flat then settles.
|
tile therefore animates the instant it mounts, with NO per-index CSS delay —
|
||||||
Honors prefers-reduced-motion. Tunables: tilt (-28deg), stagger (70ms),
|
the old `animation-delay: index×70ms` compounded on top of the insert
|
||||||
duration (0.6s). */
|
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 {
|
@keyframes fc-masonry-item-in {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -138,7 +134,6 @@ useInfiniteScroll(sentinelEl, () => {
|
|||||||
transform-origin: center top;
|
transform-origin: center top;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.fc-masonry__item--anim {
|
.fc-masonry__item--anim {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { preloadImage } from '../utils/preloadImage.js'
|
||||||
|
|
||||||
// Buffered producer/consumer so the cascade cadence is decoupled from fetch
|
// Buffered producer/consumer so the cascade cadence is decoupled from fetch
|
||||||
// latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
|
// 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
|
// sometimes-cold blocks; RTT jitter) the animation starved and the view
|
||||||
// "burped" out a clump of images. Now:
|
// "burped" out a clump of images. Now:
|
||||||
// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
|
// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
|
||||||
// target depth, refilling whenever the queue dips below BUFFER_MIN.
|
// target depth, refilling whenever the queue dips below BUFFER_MIN. It
|
||||||
// - CONSUMER (_drain): pops ONE item every CADENCE_MS, independent of when
|
// also kicks off the image PRELOAD for each queued item so decoding
|
||||||
// fetches land. It only waits if the buffer genuinely starves.
|
// pipelines ahead of the reveal.
|
||||||
// As long as fetches keep up on average (a 3-row fetch is usually well under
|
// - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
|
||||||
// the BUFFER_MIN×CADENCE runway), images appear at a perfectly even pace.
|
// decoded, then reveals it — at most one per CADENCE_MS. Because the
|
||||||
// The very first image still waits on the first fetch (a cold TABLESAMPLE is a
|
// producer preloads ahead, the decode-wait is usually already satisfied,
|
||||||
// separate, query-side concern); everything after it is buffer-smoothed.
|
// 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 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 PRIME = 6 // items buffered before the drain starts
|
||||||
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
||||||
const BUFFER_MIN = 12 // ...and refills once the queue dips below 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
|
// Internal buffer (not reactive — the consumer is what feeds the UI via
|
||||||
// images.value).
|
// images.value).
|
||||||
let queue = []
|
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
|
// Sequence token: shuffle / re-mount bumps it so in-flight producers and
|
||||||
// the drain bail instead of interleaving two runs into one images list.
|
// the drain bail instead of interleaving two runs into one images list.
|
||||||
let _seq = 0
|
let _seq = 0
|
||||||
let _filling = false
|
let _filling = false
|
||||||
let _draining = 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.
|
// One batch, retried while the random sample comes back all-duplicates.
|
||||||
// Returns the fresh items, or null when the API is genuinely empty.
|
// Returns the fresh items, or null when the API is genuinely empty.
|
||||||
async function _fetchFresh(mySeq) {
|
async function _fetchFresh(mySeq) {
|
||||||
@@ -79,6 +95,7 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
|||||||
if (mySeq !== _seq) return
|
if (mySeq !== _seq) return
|
||||||
if (batch === null) { exhausted.value = true; return }
|
if (batch === null) { exhausted.value = true; return }
|
||||||
queue.push(...batch)
|
queue.push(...batch)
|
||||||
|
batch.forEach(_preload) // pipeline decoding ahead of the reveal
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_filling = false
|
_filling = false
|
||||||
@@ -104,7 +121,10 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
|||||||
}
|
}
|
||||||
if (mySeq !== _seq) return
|
if (mySeq !== _seq) return
|
||||||
if (queue.length === 0) return // exhausted + empty
|
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)
|
await _sleep(CADENCE_MS)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,6 +140,7 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
|||||||
const mySeq = _seq
|
const mySeq = _seq
|
||||||
images.value = []
|
images.value = []
|
||||||
queue = []
|
queue = []
|
||||||
|
_preloads = new Map()
|
||||||
seen.clear()
|
seen.clear()
|
||||||
exhausted.value = false
|
exhausted.value = false
|
||||||
error.value = null
|
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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
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'
|
import { useShowcaseStore } from '../src/stores/showcase.js'
|
||||||
|
|
||||||
let nextId
|
let nextId
|
||||||
@@ -25,6 +33,8 @@ describe('showcase store: buffered cascade', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
nextId = 1
|
nextId = 1
|
||||||
|
preloadImage.mockReset()
|
||||||
|
preloadImage.mockImplementation(() => Promise.resolve())
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
})
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -60,6 +70,21 @@ describe('showcase store: buffered cascade', () => {
|
|||||||
expect(s.images.length).toBe(60)
|
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 () => {
|
it('flags an empty library without starting the cascade', async () => {
|
||||||
stubShowcase({ empty: true })
|
stubShowcase({ empty: true })
|
||||||
const s = useShowcaseStore()
|
const s = useShowcaseStore()
|
||||||
|
|||||||
Reference in New Issue
Block a user