Skip to content

Lab — the /lab pipeline composer, end to end - #4

Draft
lucasmarkes wants to merge 26 commits into
mainfrom
worktree-lab-phase1
Draft

Lab — the /lab pipeline composer, end to end#4
lucasmarkes wants to merge 26 commits into
mainfrom
worktree-lab-phase1

Conversation

@lucasmarkes

@lucasmarkes lucasmarkes commented Jul 23, 2026

Copy link
Copy Markdown
Owner

The /lab pipeline 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

  • The pipeline as data — five stages and the fire / rain / aurora / pulse presets 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.
  • A live preview that runs @lucasmarkes/motes for 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).
  • Composed from the panel's own controls, not a parallel set — the Lab reuses the same control components the library ships.
  • Shareable state — the composed pipeline round-trips through the URL.
  • Public API hardened for the Lab's sakedefineEffect / removeEffect made safe public surface (collision, SSR, HMR), cut as a minor/0.2.0 bump; README and 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:

  • ≥1600px — three columns side by side.
  • 1100–1599px — the code rail collapses to a right-edge slide-over over a backdrop; field + controls rail remain.
  • <1100px — a single stacked column.

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

  • Focus-trap on the slide-over — while the code panel is open, the field and controls rail behind its scrim are inert: dimmed and unreachable by keyboard, so Tab can't land on a control hidden under the backdrop. A matchMedia guard clears a stale open state on resize past 1600px, so inert is never applied when the code is a plain column rather than an overlay. Escape and outside-click still close it.
  • Overflow net on the railoverflow-y: auto never 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.
  • Label-in-Name — the code toggle's accessible name is its visible text (WCAG 2.5.3).

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 test stays 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

typecheck clean · fast unit 61/61 · test:glsl 4/4 · test:layout 2/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).

…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.
@lucasmarkes lucasmarkes changed the title Lab — Phase 1: pipeline model + GLSL generator + compile gate Lab — the /lab pipeline composer, end to end Jul 24, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant