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.
Where the pixels come from
Before any code runs, someone puts a drawing on a scanner. The drawings here are about 3 × 5 inches, scanned at 600 DPI, which makes each one roughly 1640 × 2530 pixels.
That number isn't chosen for being round. It's chosen backwards, from what a buyer can print: at a fine-art standard of 300 DPI, 1640 pixels of width prints about 14 cm — A5, nearly twice the original's size. Resolution is only ever meaningful relative to a physical output. "600 DPI" means nothing on its own; "prints A5 at 300 DPI" is a promise you can keep.
The scan is saved as lossless PNG, and that choice matters more than it looks. Here is the rule the whole pipeline rests on:
Concept: lossless source, lossy delivery. Keep one exact master; derive small, lossy copies from it for the browser. Never derive a copy from a copy.
The reason is that lossy formats are not idempotent. Encoding a JPEG or a WebP throws away detail to save bytes, and encoding the result throws away more — the artifacts compound. This is called generation loss, and it's the digital cousin of photocopying a photocopy.
This site learned that the hard way. The first notebook's source images were themselves lossy
WebP, so standardize-images.js re-encoded an already-lossy file to produce -lg. Comparing
one against the true PNG crop: 85% of the bytes differ, by up to 30 levels out of 255.
Nobody noticed by eye, which is exactly what makes generation loss insidious — each pass looks
fine, and the damage is cumulative and permanent.
The pipeline takes PNG masters now, so it won't happen again. But that first notebook was
never re-exported, and its -lg files are still second-generation today. That's the honest
shape of this kind of mistake: the fix protects everything after it, and the damage already
done stays done unless someone redoes the manual work by hand. Here it wasn't worth it — the
difference is invisible at the sizes the gallery actually serves. It's still a permanent
footnote in the repo, which is a better argument for getting it right the first time than any
amount of theory.
There's a second reason PNG rather than JPEG for this art specifically. JPEG compresses by discarding high-frequency detail, so it rings — puts faint halos — around hard edges. A pen drawing is nothing but hard edges. Photographs, with their soft gradients, hide JPEG's artifacts well; line art displays them.
The exact scanner and export settings live in the README — this chapter is about why, not which checkbox.
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 the source negro_2_09.png is joined by negro_2_09-sm.webp, -md.webp, and -lg.webp.
The master stays PNG and never leaves the machine; every derived file is WebP —
dramatically smaller than JPEG/PNG at comparable quality, and universally supported by browsers
for years now. That's the lossless-source/lossy-delivery split from above, expressed as file
extensions. 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").