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, sizes has 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").