Luiz Tanure
ui may 01, 2026 6 min read

Generative tile fallbacks

Empty image slots looked sad. Real photos weren't ready. So I built a deterministic Canvas2D engine that fills every missing tile with art derived from the slot's slug, plus a playground.

Most of this site is content I haven’t shot yet. The workshop pages need photos of vessels and prints. The project case studies need screenshots. Until those exist, every tile is a paper-colored gradient with a date pill on top, perfectly fine, perfectly forgettable.

I wanted the absence of a photo to say something. Not “loading,” not “TODO,” but a piece of the site’s voice that’s interesting in itself. Something deterministic, so the same workshop entry always renders the same art, and themed, so a paint tile reads differently from a 3D-print tile.

Below this post is the playground. Type a seed, pick a kind, hit shuffle, same machinery the workshop tiles use.

What I rejected

I started with static SVG riso, two-color silkscreen with offset duplicates and a grain pattern. It was the cleanest thing to build (pure functions, build-time, zero runtime JS, theme-aware via CSS variable cascade) and I had a working version inside an afternoon.

It looked polluted. Every tile carried the same dot grain. The shapes were too small. Cropping at varying tile aspects produced the worst of both worlds, a tiny silhouette stranded in a sea of paper. The signature was everywhere, which meant nowhere.

So I researched the alternatives and asked two questions: what visual register do I actually want, and what’s the smallest tool that gets me there.

OptionBundleVerdict
three.js~160 KB gzOverkill for static tiles. WebGL contexts cap around 8-16 per page.
paper.js~70 KB gzStrong at boolean path ops; we don’t need them. Re-tinting requires walking a scene graph.
d3.js~12-15 KB tree-shakenWrong category, chart kit, not drawing engine.
WebGL fragment shader~5 KB hand-rolledBeautiful for soft fields, wrong register for printed-matter blocks.
Canvas2D + shape grammar0 KB200 lines, every pixel under control, multiply-blend native, instant theme retint.

Canvas2D won. The “polluted” feeling from the SVG attempt wasn’t the medium, it was loose rules. Hand-rolled gives stricter rules for free.

What ended up working

Each kind of tile gets its own composer function: compose3d, composeLaser, composePaint, etc. The composer takes the tile’s actual (width, height) and lays out a multi-element scene that fills it. No fixed reference frame, no “scale this 500-unit illustration to fit min(w,h)”, that’s what produced the postage-stamp problem.

The 3D-print tile is a parametric city of isometric boxes scattered across the tile. Laser is a blueprint with a grid + central machine drawing. Paint is overlapping painterly smears with brush marks. Each kind picks a fixed five-token palette, [outline, mid-dark, accent, accent, lightest], so all paint tiles read clay-on-paper, all wood tiles read deep-red on paper, the workshop type is the visual identity.

Determinism is a tiny PRNG (xmur3 hash → sfc32 stream, ~30 lines, public-domain). Same slug → same float sequence → same composition. Adding a new workshop entry produces new art automatically; renaming an entry produces different art (intentional, the slug is the identity).

Theme awareness is getComputedStyle(document.documentElement).getPropertyValue('--clay'). When the user hits T to toggle workshop / editorial / dark, the canvas re-paints with the freshly resolved tokens. Same seed, same composition, different colors.

Things I had to fight

Aspect ratio. First pass scaled everything by min(w, h) and centered. Worked for square tiles, looked terrible on wide hero strips and tall span-2 cells. Fixed by passing (w, h) directly to each composer and letting it decide how to fill, some kinds (laser grid, wood grain) bleed full-frame, some (cube) stay roughly square but use the room for spacing, some (filmstrip vertical bars) span all the way across.

Theme retint without flicker. The canvas paints whatever color the tokens currently resolve to. Before the script runs, the canvas is empty, there’s a CSS class (kind-laser etc.) on the canvas that paints a sensible bg color in the meantime. SSR-safe: no JS, the right base color shows through, then on requestAnimationFrame the script paints over it.

TypeScript-strict + tuple indexing. Palette is a readonly [string, string, string, string, string]. Static index p[0] is string. Computed index p[i] is string | undefined under noUncheckedIndexedAccess. Solved by i % 3 === 0 ? p[1] : i % 3 === 1 ? p[2] : p[3], verbose, but no as casts and no helper. Worth the trade.

The architecture

src/lib/generative/
  seeded.ts     PRNG (xmur3 → sfc32)
  canvas.ts     Composers + paint() + paintFromElement()
  index.ts      Barrel
src/ui/
  Generative.astro     <canvas> + script + theme:toggle listener
  GenerativeLab.astro  Interactive playground (you're looking at it below)

paint() is pure. It takes a CanvasRenderingContext2D, dimensions, seed, kind, and palette and returns nothing. paintFromElement() is the DOM glue: reads data-seed and data-kind from the canvas, resolves the palette via getComputedStyle, calls paint(). The whole engine is around 500 lines including comments.

GenerativeLab is the same engine wired to inputs. It’s a DS leaf, the same component is listed on /design-system and embedded here in this post via the writing-post demo: frontmatter slot.

What I learned

Hand-rolling beats picking a library when the use case is small and the visual contract is yours. d3 / paper / three would all have worked. None of them would have been fewer lines than what I wrote, and I’d have inherited their priors about what “art” looks like. Canvas2D is just a drawing surface, no priors, no overhead.

The thing that fixed the “polluted” feel wasn’t switching from SVG to Canvas. It was committing to strict per-kind rules, bigger shapes, bleed off the frame, fixed palette per kind, no universal grain overlay. The SVG version with those rules would have looked fine too. The medium was a rounding error; the rules were everything.

Play

Type a seed, pick a kind, hit shuffle. Toggle the site theme (T key, or the panel bottom-right) and watch the art retint without a re-render of the page.