Images and media
This is a drawing site, so images are the product — and the single biggest performance decision
in the codebase. A scanned drawing is a multi-megabyte file; a phone on a slow connection
browsing a grid of thumbnails should never be made to download one. This chapter is the whole
journey of an image: from scan, through the build pipeline, to the <img> tag that lets the
browser pick the right file.
Four sizes per drawing
Every original scan gets three derived variants, generated by scripts/standardize-images.js
with sharp:
const targets = [
{ suffix: '-sm', width: 640 },
{ suffix: '-md', width: 1024 },
{ suffix: '-lg', width: 1920 }
];
// ...
await sharp(filePath)
.resize({ width: target.width, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(targetPath);So negro_2_09.webp is joined by negro_2_09-sm.webp, -md.webp, and -lg.webp. All variants
are WebP — dramatically smaller than JPEG/PNG at comparable quality, and universally
supported by browsers for years now. The script skips any variant that already exists, which
makes it idempotent — it runs at the start of every npm run build and costs nothing when
there's no new work.
Concept: derived assets belong to the build, not the human. Nobody hand-exports three sizes of anything; a script derives them from the one source file, and re-running it is always safe. (Same principle as the data pipelines: idempotent stages you can re-run without thinking.)
Let the browser choose: srcset and sizes
Generating four files only helps if the right one gets downloaded. That decision belongs to the
browser, and srcset/sizes is how you hand it over — here's the gallery grid's <img>
(src/lib/components/Gallery.svelte):
<img
src={image.sm}
srcset="{image.sm} 640w,
{image.md} 1024w,
{image.lg} 1920w"
sizes="(min-width: 1024px) 17vw, (min-width: 768px) 25vw, 33vw"
alt={formatTitle(image.slug)}
loading={index < 3 ? 'eager' : 'lazy'}
/>Reading it as a conversation with the browser:
srcset— "these files exist, and here are their true pixel widths" (640w,1024w,1920w).sizes— "here's how wide this image will display": a sixth of the viewport on desktop (six-column grid), a quarter on tablet, a third on phones. This mirrors the CSS grid — if the layout changes,sizeshas to change with it.The browser combines those with what only it knows — actual viewport width and the screen's device-pixel ratio — and downloads exactly one file. A phone grid cell gets the 640px file; a Retina desktop viewing the same markup may pull the 1024px one.
loading— the first three tiles load eagerly (they're above the fold and wanted immediately); everything below waits until the user scrolls near it.
Concept: responsive images are a declaration, not a calculation. The server can't know the viewport or pixel density at render time, so don't try — declare what exists (
srcset) and how it will be laid out (sizes), and let the client resolve it. It's the same shape as CSS itself: describe intent, let the browser decide.
Where the files live (and why not in the repo's build)
The variants used to ship inside the app bundle, which meant every deploy re-uploaded every
image and the build crawled. Now drawing images live in Supabase Storage (bucket
drawings), uploaded once by scripts/upload.js, and the build takes ~8 seconds because it
contains no images at all.
The database stores exactly one URL per drawing — the original's. The variant URLs are derived, not stored:
// src/lib/server/loadNotebook.ts
function variantUrl(storageUrl: string, variant: 'sm' | 'md' | 'lg'): string {
return storageUrl.replace(/\.webp$/, `-${variant}.webp`);
}That works because the naming convention is the contract: the upload script and this function
agree that a variant is "the original's filename plus a suffix," so there's nothing extra to keep
in sync — no four-column table, no risk of a row whose -md URL points at the wrong file.
Concept: don't store what you can derive. Every stored copy of a derivable fact is a chance for the copies to disagree. A naming convention plus one derivation function is a single source of truth; four URL columns are four.
The exception: OG images stay JPG
One corner of static/ deliberately breaks the everything-is-WebP rule: the Open Graph images
(static/og/*.jpg) — the preview cards shown when a page is shared on social platforms and in
chat apps. Those images aren't consumed by browsers; they're fetched by link-preview crawlers,
and some of them still don't decode WebP. A format the crawler can't read means no preview at
all — so these images stay JPG, and the build's image-conversion step explicitly leaves them
alone.
Concept: know the actual consumer of each asset. "Best format" is a property of who's reading, not of the file. Optimizations that assume a modern browser have to stop at the boundary where the reader isn't one — crawlers, email clients, embedded webviews. The rule worth keeping is the reason ("crawlers read these"), not the blanket policy ("always WebP").