|
| 1 | +# fx-lab — build contract (authoritative spec) |
| 2 | + |
| 3 | +Every contributor and build step follows THIS file. Do not invent alternative shapes. The 99 effect |
| 4 | +modules are already extracted (deterministically, verbatim) into `src/effects/`. Build everything |
| 5 | +else to fit them. |
| 6 | + |
| 7 | +## 0. Ground truth already in the repo |
| 8 | + |
| 9 | +- `src/effects/shader/<id>.js` ×11 — `export default { meta, glsl: '<void main(){...}>' }` |
| 10 | +- `src/effects/canvas/<id>.js` ×82 — `export default { meta, draw: function(ctx,w,h,t,mx,my,state){...} }` |
| 11 | +- `src/effects/dom/<id>.js` ×6 — `export default { meta, selector, _raw, init: null }` (NEEDS adaptation, see §4) |
| 12 | +- `src/registry.js` — GENERATED barrel: `export const REGISTRY = { id: module }`, `export const IDS` |
| 13 | +- `src/runtime/preamble.js` — `export const PRE` (verbatim GLSL header: `#version 300 es`, precision, uniforms `iResolution`/`iTime`/`iMouse`, `out vec4 o`, helpers `hash/noise/fbm`) |
| 14 | +- `src/runtime/noise.js` — `export const NOISE = { vhash, vnoise }`. Canvas effects that call `this.vhash`/`this.vnoise` (only `topo`, `heatmap`) require the runtime to invoke `draw` with `this === NOISE`. |
| 15 | +- `build/sections.js` — `export const SECTIONS` (12, ordered) |
| 16 | +- `build/taxonomy.js` — closed `TAGS_ALL`, `VIBES`, `validateTaxonomy(meta)` |
| 17 | + |
| 18 | +## 1. Effect metadata schema (the `meta` object) |
| 19 | + |
| 20 | +Fields already present in every module (stub values where authored fields are blank): |
| 21 | +`id, kind, name, nameLocal, section, summary, description, descriptionZh, tags, vibe, culture, |
| 22 | +accuracyNote, perf{gpu,cpu,mobileSafe}, interactive{followsCursor,trigger}, reducedMotion, |
| 23 | +deterministic, state, license, attribution`. |
| 24 | + |
| 25 | +- **Authored** (metadata pass fills): `summary` (≤120 chars, **vibe-first**: mood+visual, NOT tech), `description` (1–3 EN sentences), `tags` (4–8 from `TAGS_ALL`), `vibe` (1–4 from `VIBES`), `culture` (clean string or null), `accuracyNote` (set ONLY for procedural approximations of real cultural patterns), and corrections to `interactive.trigger` (`none|hover|click|scroll|auto`), `reducedMotion` (`freeze|static|crossfade|animate`), `deterministic` (false for unseeded `Math.random`/live `new Date()`), `state` (`{persistent:true,notes}` for effects that don't clear each frame), `attribution` (`{author,url}` when `license==='custom'`). |
| 26 | +- **Pre-filled deterministically** (refine only if wrong): `id, kind, name, nameLocal, section, descriptionZh, perf, interactive.followsCursor, license='MIT'`. |
| 27 | +- `source`, `snippet`, `mountSnippet`, `runtimeNotes`, `url`, `preview` are **DERIVED by build** — never authored, never stored in modules. |
| 28 | + |
| 29 | +JSON Schema lives at `schema/effect.schema.json` (build it from this list). |
| 30 | + |
| 31 | +## 2. Runtime — `src/runtime/` |
| 32 | + |
| 33 | +- `scheduler.js`: ONE `requestAnimationFrame` loop for all mounted effects. `IntersectionObserver` sets `canvas.__vis`; skip drawing off-screen tiles. Each `draw(t)` wrapped in `try/catch` (one effect erroring never breaks others). `t` = seconds (`performance.now()/1000` from a shared origin). `ResizeObserver` keeps physical size in sync; `DPR = Math.min(2, devicePixelRatio||1)`. Reads `matchMedia('(prefers-reduced-motion: reduce)')`; under reduce, honor each effect's `meta.reducedMotion` (default `freeze` = render exactly one frame then stop scheduling it). |
| 34 | +- `gl.js`: `makeGL(canvas, glsl)` builds a WebGL2 context using `PRE + glsl`, full-screen triangle VS (`in vec2 p; void main(){ gl_Position=vec4(p,0,1); }`), uniforms `iResolution`(physical px), `iTime`(s), `iMouse`(physical px, **Y-flipped**, defaults to center when no pointer). Keep a **module-level live-context counter**; refuse beyond cap (default 8) returning `null` + a clear `console.error` (do NOT throw). Decrement on stop/`webglcontextlost`. |
| 35 | +- `canvas.js`: per-tile 2D context with `ctx.setTransform(dpr,0,0,dpr,0,0)` (coords in CSS px). Call `module.draw.call(NOISE, ctx, w, h, t, mx, my, state)` — `this===NOISE` so `this.vhash/this.vnoise` resolve. `mx,my` in CSS px, **`-9999` when not hovering**. `state` = a per-mount `{}` persisted across frames. |
| 36 | +- `dom.js`: dispatches an adapted dom effect's `init(root)`. |
| 37 | +- `mount.js`: **`mount(el, id, opts) -> handle` where `handle.stop()` releases everything** (rAF dereg, GL context + counter decrement, observers, listeners, timers). Route by `REGISTRY[id].meta.kind`. This lifecycle is what the original demo lacked — get it right (no leaks on repeated mount/stop). |
| 38 | +- `index.js`: `export { mount, stopAll, REGISTRY, IDS, listEffects }`. `listEffects()` returns the meta array. |
| 39 | + |
| 40 | +## 3. snippet.js (build/) — the field an LLM pastes |
| 41 | + |
| 42 | +`buildSnippet(module) -> { snippet, mountSnippet }`. `snippet` is a COMPLETE zero-dependency, |
| 43 | +paste-anywhere block that runs with no imports. One `switch (kind)`: |
| 44 | +- **shader**: a `<canvas>` + inlined minimal `makeGL` harness with the `PRE` string + the effect's `glsl` + a self-contained rAF loop + pointer handling. Must run standalone. |
| 45 | +- **canvas**: a `<canvas>` + inlined `setTransform(dpr)` + a self-contained rAF loop calling the `draw` body. **If the effect uses `this.vhash/this.vnoise`, inline `vhash`+`vnoise` and call `draw.call({vhash,vnoise}, …)`.** |
| 46 | +- **dom**: the sentinel HTML (from `selector`) + the adapted `init` inlined. |
| 47 | +Prepend `attribution` (author/url) and `accuracyNote` as code comments so they travel with copied code. |
| 48 | +`mountSnippet`: short form for runtime users — `import { mount } from 'fx-lab'; mount(el, '<id>');`. |
| 49 | + |
| 50 | +## 4. The 6 dom effects need adaptation (runtime task) |
| 51 | + |
| 52 | +Each `src/effects/dom/<id>.js` has `_raw` (the original `initXxx(){...}` using `this.root`, |
| 53 | +`this.domSteps`, timers). Rewrite into `init(root) => ({ step?(t){}, stop(){} })`: |
| 54 | +`this.root` -> `root`; pushes to `this.domSteps` -> return a `step(t)`; event listeners + timers |
| 55 | +must be removable in `stop()`. Keep behavior identical. Delete `_raw` and `init:null` when done. |
| 56 | + |
| 57 | +## 5. build.mjs (build/) — fan-out, with GATES |
| 58 | + |
| 59 | +Import `src/registry.js`. Validate every `meta` against `schema/effect.schema.json` AND |
| 60 | +`validateTaxonomy`. **FAIL the build (non-zero exit) on:** duplicate `id`; unknown tag/vibe; |
| 61 | +`license==='custom'` with null `attribution`. Then derive `source`/`snippet`/`mountSnippet`/ |
| 62 | +`runtimeNotes`/`url` and write into `site/`: |
| 63 | +- `site/fx-index.json` — `{ name, version, homepage, sections, effects:[meta-without-source + url + preview] }` (validate vs `schema/manifest.schema.json`) |
| 64 | +- `site/effects/<id>.json` — full meta + source + snippet + mountSnippet + runtimeNotes (self-contained) |
| 65 | +- `site/effects/<id>.md` — H1 `name (kind)` → blockquote (visual + mood) → metadata block → `## Drop-in snippet` (fenced) → `## Runtime notes` → `## Known limitations` (accuracyNote + non-determinism/accumulator + WebGL cap for shaders) |
| 66 | +- `site/llms.txt` — H1 `FX Lab`; blockquote (what it is + runtime contract in one breath); one body paragraph printing the `VIBES` vocabulary once; then 12 `## <section.label>` with lines `- [name](<homepage>/effects/<id>.md): <summary>`; a trailing `## Optional` for the heaviest/niche effects (perf.cpu==='high' or mobileSafe===false) so short-context agents can skip. |
| 67 | +- `site/llms-full.txt` — all `<id>.md` concatenated with `---`; inline canvas snippets; for shaders link the glsl in `<id>.json` rather than inlining. |
| 68 | +- copy `schema/effect.schema.json` into `site/schema/`. |
| 69 | +`runtimeNotes` assembled from kind+perf+state+cap (shaders: "Browsers cap ~8–16 live WebGL2 contexts; safe up to ~8 shader effects per page."). |
| 70 | + |
| 71 | +## 6. gallery — site/index.html |
| 72 | + |
| 73 | +Reads `fx-index.json`, renders 12 sections of tiles, mounts each via the runtime (`dist/fx-lab.esm.js` or `src/index.js` during dev). `<head>` MUST carry |
| 74 | +`<link rel="alternate" type="text/markdown" href="/llms.txt">` and `<meta name="llms-txt" content="/llms.txt">`. Respect `prefers-reduced-motion`. |
| 75 | + |
| 76 | +## 7. tests — test/smoke.mjs |
| 77 | + |
| 78 | +(1) Node: import registry, assert each `meta` validates + taxonomy passes + no duplicate id. |
| 79 | +(2) Headless (Playwright, skip gracefully if unavailable): load each generated `snippet` standalone in a page, wait 2 rAF, assert no uncaught error and a non-blank canvas where applicable. The snippet is the ONLY use path — this guards "paste = runs". Skip pixel asserts where `deterministic===false`. |
| 80 | + |
| 81 | +## 8. Paths & conventions |
| 82 | + |
| 83 | +Repo root = the directory containing `package.json`. ESM everywhere (`"type":"module"`). No runtime deps. |
| 84 | +`site/` is generated — never hand-edit except `index.html` is a template that reads the manifest. |
| 85 | +No MCP, no CLI (non-goals). The paste IS the install. |
0 commit comments