Most web projects adopt an icon library on day one and never think about icons again. We did the opposite: we shipped the site first, let the categories crystallise over many content waves, and only then designed the icon set. The result is a symbol vocabulary that is tightly coupled to the actual subject matter — a fluid-dynamics vortex glyph looks nothing like a generic "refresh" icon, and that specificity turns out to matter enormously for learner comprehension.
The 91-Symbol SVG Sprite System
A sprite sheet is a single .svg file that contains many <symbol> elements, each with a unique id. Any page on the site can reference them with a one-liner:
<!-- inline once per page, hidden --> <svg style="display:none" aria-hidden="true"> <use href="/shared/icons.svg#wave"/> </svg> <!-- use anywhere --> <svg class="icon" aria-hidden="true"> <use href="/shared/icons.svg#wave"/> </svg>
This approach means every icon on every page is served from a single cached file. Because each <symbol> inherits currentColor, you can tint icons with a single CSS property — no separate colour variants to maintain.
Deciding which 91 symbols to include required mapping every simulation to a primary visual concept. The exercise revealed three icon families:
- Physics primitives — wave, field-line, particle, vector, orbit, pendulum, spring, collision.
- Domain markers — DNA helix, cell membrane, circuit-node, geologic-layer, prism, lens, neural-net.
- UI controls — play, pause, reset, speed-up, download, fullscreen, settings, share.
Each symbol was drawn on a 24×24 viewBox with a 1.5 px stroke weight — thin enough to read small, bold enough to register at glance. Paths were hand-traced in Inkscape, then post-processed with a Node script that strips fill, stroke and style attributes so that the consuming CSS can take full control.
// strip-attrs.mjs — post-process exported SVG symbols import { readFileSync, writeFileSync } from 'node:fs'; const src = readFileSync('icons-raw.svg', 'utf8'); const clean = src .replace(/ fill="[^"]*"/g, '') .replace(/ stroke="[^"]*"/g, '') .replace(/ style="[^"]*"/g, ''); writeFileSync('public/shared/icons.svg', clean);
One architectural decision we debated at length: should symbols carry a default fill="none" stroke="currentColor"? We ultimately placed these defaults on the .icon CSS class rather than inline on the SVG, keeping the sprite markup presentation-free and therefore composable in any context.
Why We Skipped Lucide (and Every Other Library)
Lucide is an excellent icon set for product interfaces — login forms, dashboards, navigation menus. It has roughly 1,500 symbols and is maintained by a dedicated team. We chose not to use it for three concrete reasons.
1. Semantic mismatch
A "wave" in Lucide is a decorative sine wiggle. A "wave" in our physics category needs to convey wavelength λ, amplitude A, and the sense that the shape propagates — ideally evoking the dispersion relation ω² = gk for surface waves. Generic icons simply do not carry that information density; a learner scanning the category grid reads our custom glyph as "physics of waves" and Lucide's as "decoration".
2. Bundle cost vs. reality
Tree-shaken Lucide still requires importing a component per icon, adding ~1–2 KB per symbol after gzip. Our entire 91-symbol sprite is 18 KB gzipped and cached indefinitely. The crossover point where a library would be smaller is above ~200 icons used across the site, which is far more than we need.
3. SVG fill constraints
Several Lucide symbols use hard-coded fill attributes on inner shapes for stylistic layering. When you try to restyle them with color, those fills do not respond to currentColor as expected. Auditing and patching a third-party library introduces a maintenance burden that grows every time the upstream version changes. Our hand-authored sprite has no such hidden attributes — grep -r 'fill=' public/shared/icons.svg returns zero matches.
The gen-previews.mjs Puppeteer Pipeline
Open Graph images are the first thing a user sees when a simulation link is shared on social media or in a messaging app. For 350+ simulations we needed a reproducible, automated way to generate them — not a folder of manually exported PNGs.
Our solution is gen-previews.mjs, a Node script that uses Puppeteer to headlessly load each simulation page, wait for the WebGL canvas to render its first frame, and then capture a 1200×630 screenshot:
// gen-previews.mjs (simplified excerpt) import puppeteer from 'puppeteer'; import { readFileSync } from 'node:fs'; const sims = JSON.parse(readFileSync('seeds.json', 'utf8')); const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); for (const sim of sims) { const page = await browser.newPage(); await page.setViewport({ width: 1200, height: 630 }); await page.goto(`http://localhost:4321/sims/${sim.slug}/`); // wait for canvas to have non-zero pixels await page.waitForFunction(() => { const c = document.querySelector('canvas'); if (!c) return false; const ctx = c.getContext('2d'); const d = ctx.getImageData(0, 0, 4, 4).data; return d.some(v => v > 0); }, { timeout: 8000 }); await page.screenshot({ path: `public/preview/${sim.slug}.jpg`, type: 'jpeg', quality: 82 }); await page.close(); } await browser.close();
The canvas readiness check — sampling a 4×4 block of pixels from the top-left corner — is the key heuristic. A blank canvas has all-zero RGBA values; a rendered WebGL frame does not. This avoids arbitrary setTimeout waits that would either be too short (blank screenshot) or too long (pipeline stalls).
Gotcha: WebGL contexts created in headless Puppeteer default to a software rasteriser unless you pass --use-gl=swiftshader to the browser launch arguments. Without it, simulations that call getContext('webgl2') silently fall back to a blank canvas and the pixel check never resolves.
The pipeline runs in roughly 12 minutes for 350 simulations on a GitHub Actions Ubuntu runner with 4 vCPUs. We cache the public/preview/ output directory between runs using the Actions cache action, keyed on a hash of seeds.json, so only new or changed simulations are re-screenshotted.
seeds.json — Structured Metadata for AI Image Generation
Not all simulations produce a visually distinctive first-frame screenshot. Some start in a static initial state: a blank oscillator waiting for the user to add a disturbance, or a cellular automaton grid sitting at zero. For these, we use seeds.json to specify AI-generated artwork as the OG image instead.
Each entry in seeds.json describes a simulation with enough context for an image-generation model to produce a representative visual:
{
"slug": "reaction-diffusion",
"title": "Reaction-Diffusion Patterns",
"category": "chemistry",
"ogStrategy": "ai-generated",
"prompt": "Turing pattern, Belousov–Zhabotinsky spirals, teal and dark background, macro photography style, scientific, no text",
"negativePrompt": "cartoon, watermark, logo, UI chrome",
"aspectRatio": "1200x630",
"iconSymbol": "chemistry-spiral"
}
The iconSymbol field links each entry back to the SVG sprite, so the same metadata manifest drives both the OG image strategy and the category-card icon rendered on the site. Having a single source of truth for these two concerns prevents the situation where a simulation uses a fluid-dynamics icon on screen but a crystallography image when shared on social media.
The ogStrategy field can be one of three values: "puppeteer" (live screenshot), "ai-generated" (prompt-based), or "static" (manually supplied artwork). The pipeline reads this field and routes each simulation accordingly, making it easy to override the default screenshotting behaviour for specific entries without touching the script itself.
Try It Yourself
The icon system and OG pipeline were built to serve simulations like these — open them and see what the screenshotter captures:
Closing Thought
The lesson from building this system is that visual infrastructure is worth designing from domain first principles, not from the assumption that a general-purpose library will be "good enough". For a site whose entire purpose is to make abstract scientific concepts tangible, the icons and preview images are not decoration — they are part of the explanation. A learner who sees a precisely drawn field-line glyph next to the word "Electromagnetism" has already begun to form the right mental model before clicking through.
The seeds.json manifest pattern is something we intend to extend further: future waves will use the iconSymbol field to drive animated thumbnail variants, where the sprite symbol is used as a clip mask over a looping WebGL canvas. That way, the category grid itself becomes a live preview of every simulation it contains.
Next devlog we will look at the search architecture that indexes all 350+ simulations client-side without a backend — a surprisingly tricky problem when your vocabulary includes terms like "Navier–Stokes", "Kolmogorov scale", and "eigenvector decomposition".