Before you start, read skills/building-generative-backgrounds/SKILL.md. It carries the constants that render wrong, what counts as verification, and the collection gate. Everything in it was learned by shipping a piece that looked fine on paper and wrong on screen.
pieces/_template/ folder to
pieces/<your-piece-name>/, using kebab-case for
the folder name (e.g. flow-field,
contour-grid).<title> and PIECE_ID
in the new index.html.drawFrame() body with your own
rendering logic, following the comments left in the template.docs/house-look.md (saturation 0, so it reads
monochrome out of the box), give it the five presets, and declare
its text-safe zone and anchor (see "Defaults" below).A piece itself has no build step, no dependencies, no bundler —
see "The build boundary" below. Everything is plain ES modules loaded
directly by the browser, so a piece is just an index.html
that imports from ../../shared/*.js.
The pieces have no dependencies and no build step. That is the
product: a piece is one file you can open, and a baked export is one
file you can paste anywhere. Nothing in pieces/ or
shared/ may import anything generated or require an
install step.
The microsite is different. index.html,
thumbs/ and downloads/ are generated by
tools/build.mjs from pieces.json, using
Playwright as a dev dependency. None of that reaches a user of a
piece.
pieces.json: slug,
title, blurb, tags, and the
hue/hueB/saturation from the
piece's own defaults block. These three must come FIRST
in the defaults block and in that exact order —
the verifier's parser is a regex locked to that order.
colorMode/colorA/colorB may
follow after; they aren't read by the manifest.collections.json
— the gallery groups/filters by collection, not by tag. The
build's --verify-only fails loudly if a manifest slug is
unclaimed, or if a collection names a slug that doesn't exist.npm run build.npm run collection-metrics and paste its
output over ROADMAP's "Collection constraints" table. Do this after
the build, since it measures the committed thumbnails — a
piece with no thumbnail yet has no row.It fails if the manifest, the pieces on disk, and the README
disagree, including if a piece's palette has been tuned without
updating the manifest. Run npm run verify on its own to
check without regenerating.
Re-run npm run build whenever you tune a piece:
thumbnails and downloads are captured from the live piece, so they
go stale when it changes. An unrelated npm run build is
a byte-identical no-op (thumbnail capture pins the RNG and clock) so
only pieces you actually touched show a diff in thumbs/.
npm run verifymanifest vs. pieces vs. README agreementnode tools/test-presets.mjs relaypreset mechanism on one piece; preset value/enum data on all piecesnpm run test-bake-freshbakes every piece live against today's source and validates the outputnpm run test-bakedevery committed downloads/*.html renders standalone over file://npm run test-sourceSource buttons download exact unbaked piece filesnpm run test-interactionsWake, orbit, animation, and baked-artifact browser QAnpm run audit-controlsevery control has a visible effect; run last on purpose, it's the slowest and broadest, so it can never hide a faster/narrower gate's failure behind itselfnode tools/preset-sheet.mjs, node tools/browser-matrix.mjs
and npm run collection-metrics are curation tools with no
pass/fail gate and don't run in CI at all — read their output
yourself.
test-bake-fresh proves the bake process works against
current source and says nothing about what's actually committed.
test-baked proves the committed bytes still render
standalone and is blind to a live process regression until someone
next runs npm run build. A piece whose values were
tuned but never rebuilt renders fine either way, so both gates pass
trivially on a stale download.
CI deliberately does not run npm run build itself,
since the gallery, thumbnails and downloads are committed and
GitHub Pages serves them directly — rebuilding in CI would
rewrite those committed artifacts on every run. Re-running the build
after any visual change is on you.
Serve the repo root with any static file server.
Pieces import shared code with paths like
../../shared/controls.js, so opening a piece's own folder
as the server root, or opening the file directly via
file://, will not work: ES modules are blocked over
file:// and the relative import paths assume repo-root
serving.
npx serve .
Then open http://localhost:<port>/pieces/<piece-name>/.
Pass an extraControls array to
createControlPanel({ ... extraControls }) for controls
unique to your piece (same spec shape as the shared controls:
name, label, type,
min/max/step or default, etc).
These render below the fourteen shared controls automatically. You can
also add one after the panel exists with
panel.addControl({ ... }).
Pass a defaults object to
createControlPanel({ ... defaults }) to set your piece's
own default values: saturation (0 for every piece, per
the house look), hue/hueB (exact degrees)
and colorA/colorB (the hex the picker shows
for those same two hues on first load). At saturation 0 the hues
don't show until someone raises saturation or picks the
neon preset, so composition, not colour, is what makes a
piece distinct. Mirror hue, hueB and
saturation in pieces.json exactly; the build
checks them against the running piece.
A saved localStorage value always outranks a piece's
defaults for a returning visitor. "Reset to defaults" in the panel
clears it.
Pass a presets object to
createControlPanel({ ... presets }) to add a row of chip
buttons above the controls. Keys are drawn from the shared five-name
vocabulary — whisper, ink,
neon, drift, dense — and
each value is a partial map of control name to value;
anything a preset omits falls back to the piece's own defaults.
Clicking a chip and loading ?preset=<name> share
one apply path, and moving any slider clears the active chip. See
pieces/flow-field/index.html for a worked example.
A preset value that exceeds its control's declared
min/max still renders.
node tools/test-presets.mjs <slug> checks every
preset value against window.__LF_SPECS__ and fails naming
the offending piece/preset/control/value/range, but keep values in
range by construction rather than relying on the gate to catch it
after the fact. window.__LF_PANEL__.setValue(name, value, {
persist }) is the panel's write path exposed for tooling.
If your piece renders a countable number of elements (particles,
lines, grid cells), derive the count from density rather
than hardcoding it:
const BASE_COUNT = 200; // pick a sane baseline for your piece const count = Math.round(BASE_COUNT * values.density);
If your piece holds a persistent array (particles
that carry state frame to frame, not values recomputed from scratch
each frame), re-seed that array inside the panel's
onChange callback when density changes;
recomputing count alone won't resize an existing array.
See pieces/flow-field/index.html for a worked example.
Wake is not baked into pieces. Add it to any canvas that needs it:
import { createWake } from './interactions/wake.js';
const wake = createWake({ target: document.querySelector('canvas') });
wake.setEnabled(true);shared/export.js's bakeHtml() produces a
fully standalone file: it strips the control panel, inlines all of
shared/*.js into one
<script type="module">, and hardcodes the current
control values as window.__LF_BAKED_VALUES__. The output
has zero external references and runs from anywhere, including
outside this repo. Shared modules are derived from the piece's
imports; no separate bake list is maintained.
exportSource() fetches the current route without its
query/hash and downloads those exact, unbaked bytes.
shared/project.js is where 3D capability lives and
grows. It exposes createCamera({ tilt, yaw, roll, fov, cx, cy
}) returning .project(x, y, z),
.projectPath(points) and .set(partialOptions),
plus a standalone rotatePoint. A projected point carries
a scale (the perspective divisor) so pieces can thin or
fade distant geometry; scale === 0 means the point is at
or beyond the camera's near plane and must be skipped.
3D pieces expose rotX/rotY as Pitch/Yaw and
use shared Angle as roll. createOrbit() in
shared/pointer.js is a stateless delta emitter; each drag
move calls onOrbit(dPitchRad, dYawRad) and the piece
writes that delta into rotX/rotY via
panel.setValue(..., { persist: false }), then persists
once from onSettle() on pointerup. It disables itself
under ?preview=1.
Extend the camera by adding an option, not by changing call signatures, so existing pieces keep working. Deliberately absent today: depth sorting and occlusion, matrix stacks, lighting, mesh loading, and an orthographic mode.
A piece has no bundler, transpiler, or test runner of its own. Canvas rendering is verified visually: