Files
FabledCurator/frontend/src/components/discovery/MasonryGrid.vue
T
bvandeusenandClaude Opus 4.8 3a4270e6be
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
fix(showcase): reveal each tile only once its image is fully decoded
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>
2026-06-04 07:19:44 -04:00

148 lines
5.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="fc-masonry" ref="containerEl">
<div class="fc-masonry__cols">
<div v-for="(col, ci) in columns" :key="ci" class="fc-masonry__col">
<button
v-for="item in col" :key="item.id"
class="fc-masonry__item"
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
type="button"
@click="$emit('open', item.id)"
>
<img
:src="item.thumbnail_url" :alt="`image ${item.id}`"
loading="lazy"
:style="aspectStyle(item)"
/>
</button>
</div>
</div>
<div v-if="loading" class="fc-masonry__sentinel">
<v-progress-circular indeterminate color="accent" size="28" />
</div>
<div v-else-if="hasMore" ref="sentinelEl" class="fc-masonry__sentinel" />
<div v-else-if="items.length" class="fc-masonry__end text-caption">
End.
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
const props = defineProps({
items: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
// Items at indices >= animateFromIndex get the stagger fade-in. Opt-in
// — defaults to Infinity (no animation) so views that don't want it
// (ArtistView, etc.) don't pay the layout-shift cost. ShowcaseView
// uses 0 on initial load / shuffle and prevCount on infinite-scroll
// appends.
animateFromIndex: { type: Number, default: Number.POSITIVE_INFINITY },
})
const emit = defineEmits(['load-more', 'open'])
const containerEl = ref(null)
const sentinelEl = ref(null)
const { columnCount, distribute } = usePolyMasonry(containerEl)
const columns = computed(() => distribute(props.items, columnCount.value))
// id → index lookup so we can derive the stagger from natural reading
// order even after the masonry distributes items across columns.
const idxById = computed(() => {
const m = new Map()
props.items.forEach((it, i) => m.set(it.id, i))
return m
})
function shouldAnimate(item) {
const idx = idxById.value.get(item.id)
return idx !== undefined && idx >= props.animateFromIndex
}
function aspectStyle(item) {
const w = Number(item.width)
const h = Number(item.height)
if (!w || !h) return {}
return { aspectRatio: `${w} / ${h}` }
}
// Larger rootMargin than the composable default (600px) because the
// sentinel sits at the BOTTOM of the masonry container, whose height is
// the MAX of the column heights. A single tall image (long manga page,
// panorama) in one column pushes the sentinel way past the visible
// bottom of the SHORTER columns — the user reads the short-column
// bottoms long before the sentinel comes into view, and load-more
// fires too late. 2400px ≈ 2-3 screen-heights of pre-emptive trigger,
// comfortably covering typical tall-image heights. Operator-flagged
// 2026-05-30.
useInfiniteScroll(sentinelEl, () => {
if (props.hasMore && !props.loading) emit('load-more')
}, { rootMargin: '2400px' })
</script>
<style scoped>
.fc-masonry__cols { display: flex; gap: 8px; align-items: flex-start; }
.fc-masonry__col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.fc-masonry__item {
display: block; padding: 0; border: 0; background: none;
cursor: pointer; width: 100%;
overflow: hidden; border-radius: 4px;
}
.fc-masonry__item img {
width: 100%; height: auto; display: block;
background: rgb(var(--v-theme-surface-light));
/* IR-parity hover: zoom + brighten the thumbnail (style.css ~1772). */
transition: transform 0.3s ease, filter 0.3s ease;
}
.fc-masonry__item:hover img {
transform: scale(1.03);
filter: brightness(1.1);
}
.fc-masonry__sentinel {
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
}
.fc-masonry__end { text-align: center; padding: 32px 0; }
/* Cascade entry: each tile flips up out of a backward tilt and settles
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;
transform: perspective(1000px) rotateX(-28deg) translateY(26px) scale(0.95);
}
55% { opacity: 1; }
100% {
opacity: 1;
transform: perspective(1000px) rotateX(0deg) translateY(0) scale(1);
}
}
.fc-masonry__item--anim {
transform-origin: center top;
backface-visibility: hidden;
animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.fc-masonry__item--anim {
animation: none;
opacity: 1;
transform: none;
}
.fc-masonry__item img { transition: none; }
.fc-masonry__item:hover img { transform: none; }
}
</style>