Lab — the /lab pipeline composer, end to end - #4
Draft
lucasmarkes wants to merge 26 commits into
Draft
Conversation
…le gate The Lab composes an ASCII effect from a fixed five-stage pipeline (turbulence, pattern, flow, mask, shape) rather than a node graph or raw GLSL. This lands the model and the code generator, with the UI still to come. - pipeline.ts: the stage model as data, plus the four presets (fire, rain, aurora, pulse) that are the Lab's entry point. Presets are just stage configs — no special-casing. - codegen.ts: generateField(config) bakes the params in as literals and emits readable GLSL: real indentation, a comment naming the active stages, and a note that the pointer layer applies after field() returns. By construction it references no pointer identifier. - codegen.test.ts: the generator's guarantees — exact field signature, golden-rule compliance, literals baked, stages conditional, determinism. - compile.test.ts: the check the string tests can't make. It assembles each preset exactly as the renderer does and compiles + links it in a real WebGL2 context via Playwright. Kept out of the fast unit run (its own vitest.glsl.config.ts + `test:glsl` script), same split as `test:browser`. All four presets compile and link. Generator proven before any controls sit on top of it.
The preview compiles each pipeline config into the same GLSL string the
output tab will show and swaps to it on a debounced edit, so it cannot
drift from the paste. Fresh, versioned effect names force set({effect})
to relink; a new core removeEffect() prunes the old ones so the registry
does not grow for the length of a session. A compile guard keeps the
last-good program bound when a field fails to link.
Nothing in the generated field touches the pointer — the cursor reacts
because the renderer applies it after field() returns. Verified in a real
browser: fire renders bottom-anchored and licking, and a differential
region test shows the pointer's effect is localized to the cursor.
Turbulence now animates both warp components (the x-warp was frozen), and
the center mask's height-normalization is kept and commented as the
deliberate choice that keeps a pulse circular on wide viewports.
removeEffect is public surface now, not an internal Lab helper, so it is
guarded the same way collisions are: removing a built-in (flow, waves,
pulse) throws unless you pass { override: true }. The protected set is
snapshotted from exactly what registers at load, so it can never drift
from the built-ins it guards.
Documented alongside defineEffect in the root README, the core README,
and CONTRIBUTING — including that it frees no GPU resources: the compiled
program is renderer-owned and released on swap or destroy, not on prune.
New public API is a minor, so both packages move to 0.2.0 in lockstep
(the release gate and CI refuse a version mismatch).
Phase 3: the full composer UI. Extract two controls from the effect panel so the Lab reuses them rather than reimplementing: - Segmented — the sliding-pill .seg control, lifted verbatim from the panel's effect selector. Now drives the preset row and the pattern/ flow/mask enums, and a value matching no option shows no pill, which is how the preset row reads once you've edited away from every preset. - Toggle — the two-line switch, shared by the panel's Interaction and the Lab's Flicker and Interaction. The panel is refactored onto both, so there is one set of controls. LabPanel lays out one group per pipeline stage — Turbulence, Pattern, Flow, Mask, Shape — bracketed by the Preset row above and Pointer/Look below, the same controls the effect panel dresses its field with. Lab holds the split state: stage (recompiles the field) and look (live uniforms, no effect). The active preset is derived from the stage, so the row relights the moment the pipeline matches a preset again. Ambient speed is deliberately omitted — Flow carries the field's motion. Verified with Playwright: presets switch and recompile, editing darkens the preset row, still/none disable their sliders, the interaction toggle disables radius/force, and the effect page is unregressed after the refactor. 108 tests, typecheck, release gate all green.
0.1.3 is tagged and published on npm (latest, both packages, 2026-07-23) — the diagnostics reached npm there, not pending inside 0.2.0. So 0.2.0's notes carry only removeEffect (+ the defineEffect hardening still to land in Phase 5), and 0.1.3 gets its own entry for the diagnostics it already shipped.
The composition is now something you can take with you. A name field (default 'mine', sanitized only where it lands in code) labels the effect in both output files; the two-tab code block hands you effects.ts — the exact GLSL the preview compiled — and App.tsx, which imports it and renders <Motes> with the pinned full-bleed className and only the look values that differ from the defaults. Copy is one button. The whole composition is the URL: every edit rewrites the query string in place (replaceState, so a keystroke doesn't stack history), and a fresh load decodes it back — defensively, per field, so a truncated or tampered link degrades to a sensible field rather than a blank page. Any Lab session is a link you can send. The code block is lifted whole into controls/CodeOutput so the Lab's output and the effect panel's snippet are the same block — same copy affordance, same measured underline — not a second copy of it; the panel is refactored onto it rather than keeping its inline version. The pinned pre is now capped at 40vh with its own scroll: the panel's ten-line snippet never reached it, but the Lab's forty-line field, left unbounded, grew until it swallowed the controls above it. url.ts and source.ts are pure logic, built test-first (18 tests): round-trips every preset, preserves charsets-with-spaces and #hex accents, falls back per field on bad input, and emits only the look that differs.
Phase 5: the Lab becomes reachable and the door it opens gets a lock.
Site wiring (apps/playground):
- Header nav gains a `lab` link, same treatment as github/npm/x.
- The fourth index tile ("yours") keeps its live rain art and its title but
now reads `defineEffect('yours')` and leads to `/lab` with the rain preset
already in the URL — you click rain, you land editing rain. The /rain route
stays alive; the tile just stops pointing at it.
- Lab reads its opening config from the URL in a per-mount lazy initializer,
not a module constant, so client-side navigation into /lab honours the query
the tile carries instead of a stale default frozen at page load.
API hardening (packages/core):
- defineEffect now refuses to overwrite a built-in (flow/waves/pulse) unless
passed { override: true } — the same guard removeEffect already gives them —
and warns once in development when a changed definition overwrites one of your
own names. Identical re-registration (StrictMode/HMR) stays silent. TDD.
- Extract the NOISY dev gate to core/src/dev.ts so motes.ts and registry.ts
share one copy of the bundler-substitution logic instead of drifting.
CHANGELOG: the defineEffect guards join removeEffect under 0.2.0.
The right rail was the wrong shape for a wide screen: five stages fought over ~400px while the field floated in the rest and the code hid below a fold. Split the composer into three zones, each with one job. The field takes the width. The code panel keeps the only rail, because GLSL is the one thing whose shape is tall and narrow. The pipeline lies flat along the foot as a dock. The dock is two tiers, and the split is the architecture: four columns (Pattern, Flow, Mask, Shape) compile to the GLSL of effects.ts; the strip below is the look and pointer props on <Motes>, which is App.tsx. Each tier is marked with its file. A stage with one value folds it into its header instead of spending a labelled row on it — half the rows, same information — and Shape absorbs turbulence so five stages read as four columns. Presets now render three states from one source, the stage: filled when the field is exactly a preset, ink-outlined when it began there and has since been edited, muted otherwise. The row keeps a lit anchor to reset to instead of going dark on the first tweak. Below ~1100 the columns fold two-by-two and the code panel drops under the dock — the field still takes the top, the pipeline still lies flat. Not a fall back to the rail. Scoped the effect page's stacked-layout rules to .stage-shell so they stop reaching the Lab's reused header and hint. Dropped the lab link from the header nav (the 'yours' tile is the door) and raised the recompile debounce to 180ms.
…d noise Two things kept the generated fire looking like mush, and both were in the generator the layout task was told not to touch. The domain warp applied the same displacement everywhere, so the base was as chaotic as the tip — the opposite of a flame, which is laminar where it is fed and only breaks up as it rises. Compute a source term from the chosen mask (1 at the source edge, 0 far from it) and weight the warp by (1 - src). Now the base is stable and the tip is free. It generalizes: the same term gives the aurora a calm top edge and the pulse a clean centre. The mask reuses the same value it already needed, so it costs one line. The fbm was isotropic, which makes round blobs. While flowing, raise the x frequency and lower the y so the noise stretches along the flow axis and reads as rising streaks; stay isotropic when still, where there is no axis. Re-tuned the turbulence range: the gain dropped from x4 to x2 and the slider max from 4 to 3, so the top of the range is +/-3 cells at the tip instead of +/-8, past which no structure survives. With the base now spared by the source term, the range is usable end to end. Re-tuned the fire preset against the live preview — turbulence 2.7, mask falloff 1.3, contrast 1.2 — so it opens as a flame that rises through the lower field and licks apart at the tips, not a static band at the foot.
The index tile and the effect switcher both said 'yours' but led two
different ways — the tile into the Lab, the tab to /rain. Label both 'lab'
and point both at /lab: the destination is titled Lab, so the word should
name where you land. 'yours' stays only where it is the proof — the
defineEffect('yours') the Lab hands you, the effect you make.
That orphans /rain, so retire it: nothing links there now, and the Lab
composes what it demonstrated. Its route forwards to /lab with the rain
preset loaded — you land editing rain rather than on a dead URL — via a
replaceState redirect, so the back button doesn't loop through it.
The field was a grid column, inset and rounded, boxed to the space left of the rails. On the effect pages it bleeds edge to edge behind everything; the Lab now matches. The field is a full-viewport layer, and the controls and code rails float over it as panels — the same paper surface and shadow the effect page's panel already used — instead of columns competing with it for width. This holds because the library takes pointer events on the window and hit-tests them against the canvas box, so the field goes on reacting to the cursor even under the panels. The rails' height budget is unchanged — top-to-bottom of the viewport less the gutter, as the grid row was — and the standing layout test confirms they still don't scroll.
Every route mounted its own <Motes>, so moving between an effect page and the Lab destroyed and recreated a WebGL context — a hard cut. Lift one field above the router (Field.tsx): a single context, created once and kept for the app's lifetime, steered through an imperative handle rather than declarative props, so the Lab's live-compile can drive the same instance without <Motes> re-asserting props over it on every render. Routes repoint the field instead of remounting. Effect pages become chrome only; the Lab borrows the shared instance (useLabField, the old LabPreview machinery moved intact) and on exit repoints to a builtin before pruning its __lab_N, so the renderer is never left bound to a deleted effect. The index keeps its own canvases — hero and tiles are genuinely distinct fields — and hides+stops the shared field behind them. Shells go transparent so the one field shows through; the mobile field becomes a fixed backdrop rather than an inline card.
The tile-to-page numbers were the wrong asymmetry: a 200ms entrance and a 120ms exit, both on the shared ease. The entrance snapped to size and the exit read as a cut rather than a shrink-back. Split motes-field out of the root cross-fade (which stays 260ms) and tune the three parts of the one gesture: the group and the entrance both run 280ms on a strong ease-out, so the expand decelerates into place and the field fades in exactly as the motion settles. The exit runs 220ms ease-in — crisper than the entrance, but a visible movement now, and its ease-in spends the most-magnified, blurriest frames of the outgoing preview at low opacity. The group is >= its slowest child, so nothing clips.
Replace the "lab" tile and tab with "more" pointing at /effects — a gallery of ready-made effects (rain, fire, aurora, pulse). Promote fire and aurora from Lab presets to static defineEffect registrations. The Lab composer stays in src/lab/ and remains reachable at /lab for recovery and tests, but nothing in navigation links to it.
The Lab composer is no longer reachable by URL. Layout tests now mount Field + Lab through a standalone fixture so the hidden code keeps running without a routed entry point.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The
/labpipeline composer, built end to end — a live playground where a five-stage GLSL pipeline (turbulence → pattern → flow → mask → shape) is composed from real controls, previewed by the actual library, and exported as copy-paste code.What the branch delivers
fire/rain/aurora/pulsepresets are plain stage configs; a GLSL generator bakes params as literals and emits readable field code. A live-WebGL2 compile gate (Playwright) links every preset exactly as the renderer does.@lucasmarkes/motesfor real, relinking the effect as sliders move (~3ms; coalesced by a 16ms debounce so a drag tracks the field instead of settling only on release).defineEffect/removeEffectmade safe public surface (collision, SSR, HMR), cut as a minor/0.2.0bump;READMEand changelog updated.The layout rework (this session)
The composer moved from a horizontal foot-dock to three vertical zones — field | controls rail | code rail — with a responsive ladder:
The dock's pipeline stood upright into a rail, each stage folding its one value into its header row (so the full-width track pays back the label it drops).
Accessibility & robustness
inert: dimmed and unreachable by keyboard, so Tab can't land on a control hidden under the backdrop. AmatchMediaguard clears a stale open state on resize past 1600px, soinertis never applied when the code is a plain column rather than an overlay. Escape and outside-click still close it.overflow-y: autonever engages at the sizes we design for, but keeps the rail usable under reader-side variance (text zoom, a substituted font) instead of letting content bleed past the rounded panel.Test wiring
Two browser-driven gates stay out of the fast unit run, each with its own config + on-demand script (mirroring the repo's existing split);
pnpm teststays browser-free:test:glsl— compiles + links every preset's field in a live WebGL2 context.test:layout— boots the app through Vite in-process and asserts the controls rail fits without scrolling at 1920×1080 and 1440×900. This stands guard on the height budget that was previously verified by a throwaway measurement; it immediately caught a real 41px overflow at 1440 (the toggle's row), fixed by tightening the ≤1600 tier. Final slack: 30px @1440, 156px @1920.Verification
typecheckclean · fast unit 61/61 ·test:glsl4/4 ·test:layout2/2.Built across phases in an isolated worktree; executed task-by-task with per-task and a final whole-branch review (READY TO MERGE — no Critical/Important; all Minor resolved).