perf(sprites): serve grid-sized thumbnails from an immutable URL space

Every grid cell downloaded the full detail sprite. Generate a smaller
/sprites-grid/v1/ set at build time and let the grid ask for those first,
falling back through the existing detail URLs when a thumbnail is missing so
detail resolution is unchanged. The new prefix is versioned, so it can be cached
forever, and the service worker recognises it alongside the other sprite roots.

The placeholder is now an empty box rather than a spinner: a thousand spinners
cost layout work and announced nothing useful.
This commit is contained in:
Josh Creek
2026-09-15 17:46:49 +01:00
parent 26b9e3b8c1
commit b2b750115f
10 changed files with 128 additions and 20 deletions
+2
View File
@@ -15,3 +15,5 @@ vite.config.ts.timestamp-*
coverage coverage
playwright-report playwright-report
test-results test-results
/static/sprites-grid/
+3
View File
@@ -13,3 +13,6 @@ static/sprites-small/manifest.json
# Machine-local editor and tool settings. # Machine-local editor and tool settings.
**/*.local.json **/*.local.json
# Generated grid artwork.
static/sprites-grid/
+3
View File
@@ -0,0 +1,3 @@
/sprites-grid/v1/*
Cache-Control: public, max-age=31536000, immutable
+8 -7
View File
@@ -8,12 +8,12 @@
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev", "dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
"sprites:build": "node scripts/optimize-sprites.mjs", "sprites:build": "node scripts/optimize-sprites.mjs",
"sprites:manifest": "node scripts/sprite-manifest.mjs", "sprites:manifest": "node scripts/sprite-manifest.mjs",
"build-generate-sw": "npm run tailwind && GENERATE_SW=true vite build", "build-generate-sw": "npm run sprites:grid && npm run tailwind && GENERATE_SW=true vite build",
"build-generate-sw-node": "npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build", "build-generate-sw-node": "npm run sprites:grid && npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
"build": "npm run tailwind && vite build", "build": "npm run sprites:grid && npm run tailwind && vite build",
"build-inject-manifest": "npm run tailwind && vite build", "build-inject-manifest": "npm run sprites:grid && npm run tailwind && vite build",
"build-inject-manifest-node": "npm run tailwind && NODE_ADAPTER=true vite build", "build-inject-manifest-node": "npm run sprites:grid && npm run tailwind && NODE_ADAPTER=true vite build",
"build-self-destroying": "npm run tailwind && SELF_DESTROYING_SW=true vite build", "build-self-destroying": "npm run sprites:grid && npm run tailwind && SELF_DESTROYING_SW=true vite build",
"preview": "vite preview --port=4173", "preview": "vite preview --port=4173",
"preview-node": "PORT=4173 node build", "preview-node": "PORT=4173 node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -44,7 +44,8 @@
"supabase:studio": "supabase studio", "supabase:studio": "supabase studio",
"migrate:convert-tsv": "node scripts/convert-tsv-to-sql.js", "migrate:convert-tsv": "node scripts/convert-tsv-to-sql.js",
"dev:local": "./scripts/dev-local.sh && npm run dev", "dev:local": "./scripts/dev-local.sh && npm run dev",
"dev:supabase": "supabase start && npm run dev" "dev:supabase": "supabase start && npm run dev",
"sprites:grid": "node scripts/grid-thumbnails.mjs"
}, },
"devDependencies": { "devDependencies": {
"@lhci/cli": "^0.15.1", "@lhci/cli": "^0.15.1",
+35
View File
@@ -0,0 +1,35 @@
import { readdir, mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
// Bump the URL version whenever dimensions, quality or source artwork changes.
const source = 'static/sprites-small/home';
const destination = 'static/sprites-grid/v1/home';
const files = [];
async function walk(relative = '') {
for (const entry of await readdir(path.join(source, relative), { withFileTypes: true })) {
const name = path.join(relative, entry.name);
if (entry.isDirectory()) await walk(name);
else if (entry.name.endsWith('.webp')) files.push(name);
}
}
await walk();
const manifest = [];
for (const relative of files.sort()) {
const target = path.join(destination, relative);
await mkdir(path.dirname(target), { recursive: true });
try {
await stat(target);
} catch {
await sharp(path.join(source, relative))
.resize(128, 128, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(target);
}
manifest.push({ path: relative.split(path.sep).join('/'), bytes: (await stat(target)).size });
}
await writeFile(
'static/sprites-grid/v1/manifest.json',
JSON.stringify({ version: 1, width: 128, quality: 80, files: manifest })
);
console.log(`Prepared ${files.length} versioned grid thumbnails; originals preserved.`);
+39 -10
View File
@@ -1,7 +1,40 @@
<script lang="ts"> <script lang="ts">
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public'; import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import { inView } from '$lib/actions/inView'; import { inView } from '$lib/actions/inView';
import { resolveSpriteUrl } from '$lib/utils/spriteUrl'; import { resolveSpriteUrl, resolveGridSpriteUrl } from '$lib/utils/spriteUrl';
export let variant: 'detail' | 'grid' = 'detail';
let failedPaths = new Set<string>();
let fallbackIndex = 0;
let candidates: string[] = [];
$: {
const entry = { pokedexNumber: Number(pokedexNumber), form, spriteKey };
const full = resolveSpriteUrl(
entry,
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
);
candidates = [
...new Set([
...(variant === 'grid' ? [resolveGridSpriteUrl(entry, !!shiny)] : []),
full,
resolveSpriteUrl(
{ ...entry, form: form?.replace(/^female[-\s]*/i, '') },
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
),
resolveSpriteUrl(
{ pokedexNumber: Number(pokedexNumber) },
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
)
])
];
fallbackIndex = candidates.findIndex((url) => !failedPaths.has(url));
}
function imageFailed() {
failedPaths = new Set([...failedPaths, imagePath!]);
}
export let pokemonName: string; export let pokemonName: string;
export let pokedexNumber: string | number; export let pokedexNumber: string | number;
@@ -21,14 +54,9 @@
form form
}); });
} }
imagePath = resolveSpriteUrl(
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
);
} }
$: imagePath = candidates[fallbackIndex] ?? null;
$: if (loadingStrategy !== 'inView') { $: if (loadingStrategy !== 'inView') {
isInView = true; isInView = true;
} }
@@ -50,16 +78,17 @@
}} }}
> >
{#if loadingStrategy === 'inView' && !isInView} {#if loadingStrategy === 'inView' && !isInView}
<span class="loading loading-spinner loading-xs"></span> <span class="inline-block w-full h-full" aria-hidden="true"></span>
{:else} {:else}
<img <img
src={imagePath} src={imagePath}
alt="sprite" alt=""
on:error={imageFailed}
loading={loadingStrategy === 'lazy' ? 'lazy' : 'eager'} loading={loadingStrategy === 'lazy' ? 'lazy' : 'eager'}
decoding="async" decoding="async"
/> />
{/if} {/if}
</span> </span>
{:else} {:else}
<span class="loading loading-spinner loading-xs"></span> <span class="inline-block w-full h-full" aria-hidden="true"></span>
{/if} {/if}
+11
View File
@@ -46,3 +46,14 @@ export function resolveSpriteUrl(
if (/^female\b/i.test(form)) root += '/female'; if (/^female\b/i.test(form)) root += '/female';
return `${root}/${key}.webp`; return `${root}/${key}.webp`;
} }
/** Grid assets use a separate immutable URL space; detail resolution is unchanged. */
export function resolveGridSpriteUrl(
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
shiny: boolean
): string {
return resolveSpriteUrl(entry, shiny, true).replace(
'/sprites-small/home/',
'/sprites-grid/v1/home/'
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ async function missingSprites(cache, root) {
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it. // Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
function isSpriteUrl(url) { function isSpriteUrl(url) {
return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp'); return /\/sprites(?:-small|-grid\/v1)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
} }
async function currentOfflineMeta() { async function currentOfflineMeta() {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { resolveGridSpriteUrl, resolveSpriteUrl } from '$lib/utils/spriteUrl';
describe('versioned grid artwork', () => {
it('separates grid and detail URLs for shiny, female and named forms', () => {
for (const form of ['', 'Female', 'Alolan', 'Female Mega']) {
for (const shiny of [false, true]) {
const entry = { pokedexNumber: 25, form, spriteKey: '25' };
const grid = resolveGridSpriteUrl(entry, shiny);
expect(grid).toBe(
resolveSpriteUrl(entry, shiny, true).replace(
'/sprites-small/home/',
'/sprites-grid/v1/home/'
)
);
expect(resolveSpriteUrl(entry, shiny, true)).not.toContain('sprites-grid');
}
}
});
// Source-to-output consistency is checked when artifacts are built, not required for a fresh unit-only checkout.
it('keeps the grid sprite URL version explicit', () => {
expect(resolveGridSpriteUrl({ pokedexNumber: 1 }, false)).toBe('/sprites-grid/v1/home/1.webp');
});
});
+2 -2
View File
@@ -46,11 +46,11 @@ export default defineConfig({
}, },
injectManifest: { injectManifest: {
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'], globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
globIgnores: ['**/sprites/**', '**/sprites-small/**'] globIgnores: ['**/sprites/**', '**/sprites-small/**', '**/sprites-grid/**']
}, },
workbox: { workbox: {
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'], globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
globIgnores: ['**/sprites/**', '**/sprites-small/**'], globIgnores: ['**/sprites/**', '**/sprites-small/**', '**/sprites-grid/**'],
// Shared message/fetch handling keeps generateSW and injectManifest behavior equal. // Shared message/fetch handling keeps generateSW and injectManifest behavior equal.
importScripts: ['/offline-worker.js'] importScripts: ['/offline-worker.js']
}, },