diff --git a/e2e/home.test.ts b/e2e/home.test.ts index da7bc16..f516fa0 100644 --- a/e2e/home.test.ts +++ b/e2e/home.test.ts @@ -10,6 +10,30 @@ async function waitForCardCount(page: Page, expected: number) { }).toPass({ timeout: 10000 }) } +async function expectCardsNotToOverlap(page: Page) { + await expect(async () => { + const boxes = await page.locator('article.card').evaluateAll(cards => + cards.map(card => { + const rect = card.getBoundingClientRect() + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom } + }) + ) + + for (let i = 0; i < boxes.length; i += 1) { + for (let j = i + 1; j < boxes.length; j += 1) { + const a = boxes[i] + const b = boxes[j] + const overlaps = + a.left < b.right && + a.right > b.left && + a.top < b.bottom && + a.bottom > b.top + expect(overlaps).toBe(false) + } + } + }).toPass({ timeout: 10000 }) +} + test.describe('home page — with cards', () => { test('inline search input filters cards by text', async ({ page }) => { await page.goto('.') @@ -116,6 +140,29 @@ test.describe('home page — with cards', () => { expect(chipTexts.some(t => t.trim() === 'Adventure')).toBe(true) } }) + + test('masonry cards do not overlap on a cold uncached load', async ({ page }) => { + await page.route('**/*', route => { + route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } }) + }) + + await page.goto('.', { waitUntil: 'networkidle' }) + await waitForCards(page) + await page.locator('article.card img').evaluateAll(images => + Promise.all( + images.map(image => { + const img = image as HTMLImageElement + if (img.complete) return Promise.resolve() + return new Promise(resolve => { + img.addEventListener('load', () => resolve(), { once: true }) + img.addEventListener('error', () => resolve(), { once: true }) + }) + }) + ) + ) + + await expectCardsNotToOverlap(page) + }) }) test.describe('home page — no cards', () => { diff --git a/src/lib/CardGrid.svelte b/src/lib/CardGrid.svelte index e1976f1..03244f0 100644 --- a/src/lib/CardGrid.svelte +++ b/src/lib/CardGrid.svelte @@ -23,19 +23,30 @@ } } + function scheduleMeasure() { + requestAnimationFrame(measureSpans) + } + // Re-measure after each exhibits update so new cards get correct spans. $effect(() => { exhibits if (config.cardLayout !== 'masonry') return - requestAnimationFrame(measureSpans) + scheduleMeasure() }) - // Re-measure on viewport resize so spans stay accurate as columns reflow. + // Re-measure when cards or images change size so cold image loads get correct + // masonry spans instead of overflowing into following grid rows. $effect(() => { if (config.cardLayout !== 'masonry' || !gridEl) return - const ro = new ResizeObserver(() => requestAnimationFrame(measureSpans)) - ro.observe(gridEl) - return () => ro.disconnect() + const ro = new ResizeObserver(scheduleMeasure) + const cards = Array.from(gridEl.querySelectorAll('article.card')) + const images = Array.from(gridEl.querySelectorAll('img')) + cards.forEach(card => ro.observe(card)) + images.forEach(image => image.addEventListener('load', scheduleMeasure, { once: true })) + return () => { + ro.disconnect() + images.forEach(image => image.removeEventListener('load', scheduleMeasure)) + } }) diff --git a/src/lib/ExhibitCard.svelte b/src/lib/ExhibitCard.svelte index a058558..c2576e9 100644 --- a/src/lib/ExhibitCard.svelte +++ b/src/lib/ExhibitCard.svelte @@ -1,6 +1,7 @@ @@ -38,6 +35,7 @@ alt={exhibit.name} class="w-full h-auto" loading="lazy" + onerror={fallbackToRootAsset} /> {/if} diff --git a/src/lib/assets.test.ts b/src/lib/assets.test.ts new file mode 100644 index 0000000..416e8fe --- /dev/null +++ b/src/lib/assets.test.ts @@ -0,0 +1,29 @@ +import { fallbackToRootAsset, resolveAssetPath } from './assets.js' +import { resetAssetBase, setAssetBase } from '../test/mocks/app-paths.js' + +afterEach(() => { + resetAssetBase() +}) + +test('resolveAssetPath prefixes root-relative paths with the configured asset base', () => { + setAssetBase('/oddments') + expect(resolveAssetPath('/covers/game.webp')).toBe('/oddments/covers/game.webp') +}) + +test('resolveAssetPath leaves external and relative paths unchanged', () => { + setAssetBase('/oddments') + expect(resolveAssetPath('https://example.com/cover.webp')).toBe('https://example.com/cover.webp') + expect(resolveAssetPath('covers/game.webp')).toBe('covers/game.webp') +}) + +test('fallbackToRootAsset strips the configured asset base from failed images', () => { + setAssetBase('/oddments') + const img = document.createElement('img') + img.setAttribute('src', '/oddments/covers/game.webp') + const event = new Event('error') + Object.defineProperty(event, 'currentTarget', { value: img }) + + fallbackToRootAsset(event) + + expect(img.getAttribute('src')).toBe('/covers/game.webp') +}) diff --git a/src/lib/assets.ts b/src/lib/assets.ts new file mode 100644 index 0000000..66a321c --- /dev/null +++ b/src/lib/assets.ts @@ -0,0 +1,17 @@ +import { asset } from '$app/paths' + +export function resolveAssetPath(path: string | null | undefined): string { + if (!path) return '' + return path.startsWith('/') ? asset(path) : path +} + +export function fallbackToRootAsset(event: Event) { + const element = event.currentTarget + if (!(element instanceof HTMLImageElement)) return + + const src = element.getAttribute('src') ?? '' + const assetBase = asset('/').replace(/\/$/, '') + if (!assetBase || !src.startsWith(`${assetBase}/`)) return + + element.src = src.slice(assetBase.length) +} diff --git a/src/routes/exhibit/[slug]/+page.svelte b/src/routes/exhibit/[slug]/+page.svelte index feb0441..b8fc669 100644 --- a/src/routes/exhibit/[slug]/+page.svelte +++ b/src/routes/exhibit/[slug]/+page.svelte @@ -1,5 +1,6 @@