linefield
Docs / Contributing — stub, ported from CONTRIBUTING.md

Contributing to linefield.

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.

Adding a new piece

  1. Copy the whole pieces/_template/ folder to pieces/<your-piece-name>/, using kebab-case for the folder name (e.g. flow-field, contour-grid).
  2. Update the <title> and PIECE_ID in the new index.html.
  3. Replace the drawFrame() body with your own rendering logic, following the comments left in the template.
  4. Set your piece's defaults to the house look in 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).
  5. Verify visually in a browser (see "Verifying a piece" 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 build boundary

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.

Serving the project

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>/.

The 14 shared visual controls

Every piece gets these controls for free via createControlPanel() (shared/controls.js):

colorMode (Solid/Gradient) and colorA/colorB (hex colour pickers, colorB only shown in Gradient mode) are the authored palette — a piece never reads them directly. Call paletteHsl(values, f) from shared/color.js instead: it returns { h, s } for a 0-1 position along the ramp (f=0 is stop A, f=1 is stop B; Solid mode ignores f and always returns stop A), sourcing s from the shared saturation control. hue and hueB still exist in values, hidden from the panel, but they are implementation values colorA/colorB drive one-way; a piece's own code should never read them.

A new piece should make each authored control visibly affect its render. If a control doesn't map naturally onto your piece's visuals, find a reasonable interpretation (e.g. angle can rotate a field, motion can scale a secondary animation speed distinct from speed) rather than skipping it.

Piece-specific controls (extraControls)

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({ ... }).

Defaults

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.

Presets

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.

Density convention

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.

Optional interaction mechanisms

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);

Baked HTML export

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.

3D pieces

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.

Verifying a piece

A piece has no bundler, transpiler, or test runner of its own. Canvas rendering is verified visually: