videoskilletnotes Open the app ↗
On this page

AI usage

Agents wrote most of this codebase, and the app is built to be played by an agent as well as by a person. Three jobs bring an agent here, and each wants a different part of this page: operating the panel from inside a browser, driving the board by link, and changing the code.

Here is one doing it. Claude Sonnet on the right, the app in a Chrome on the left, and nothing between them scripted — the recording is pnpm agentreel, which sets the two windows down side by side, hands a real Claude Code session the task, and stops when it lands:

Every press in it comes from the model. It types five controls into the palette in turn — sync suppression, subcarrier detune, chroma gain, HV sag, supply ring — checking each row before committing it, and each one lands on what the last one did: a photograph scrambles into inverted bands, the hue barber-poles down the frame, the whole thing goes rainbow, and the geometry bends it into waves. Then it reads the board back. DEVELOPMENT.md § Recording an agent driving the app is how to run it.

Operating the panel from inside a browser#

An agent that can see a page and type into it — Claude in Chrome, or anything else browsing on a person's behalf — reaches this app the way a person does, at videoskillet.com/app/. The app needs WebGPU, which Chrome ships on desktop; an older Linux build may still want it switched on at chrome://flags.

The command palette is the surface built for exactly this. ctrl+k (⌘k) puts a region labelled command palette in the sidebar, holding one text input, and the input takes a control's name and the value to set it to in one string:

head switch 9      the head-switch tear, pulled to 9 µs
noise 12           luma noise to 12 IRE
synth mix ring     a mode switch, by a prefix of the option's name
vhs                a preset by name, applied whole
still              a verb — the palette indexes actions too

Enter commits. The reason the box takes a value at all is written in src/ui/paletteQuery.ts: an agent cannot aim at a slider track, and nudging 0 to 9 IRE with an arrow key is thirty presses. Naming the control and the number is the one gesture that is faster from outside the app than from inside it.

Read the board back off the list, not off the picture. Every palette row is a button carrying, as text, the name, the control's current reading, the kind and the group — and the row under the cursor shows current → target before Enter is pressed, so an agent can check what a press is about to do. Typing head switch 9 puts this on the first row:

head switch | 0.80us → 9.00us | CONTROL | Timebase

The picture is a <canvas>: a screenshot says what the fault looks like and never what a control is set to.

board as text answers the same question in one read. The palette's list answers one query at a time, and the address bar's wire keys carry no units, so board as text puts the whole state in one readable block: the look, both decks, every control off stock with its reading and its stock value, every routing the modulation bay is driving, and the link. It sits in the sidebar, in a region labelled board as text:

look      modified from “vhs”
source A  Color bars
source B  nothing patched in

11 controls off stock
  Timebase
    head switch   headSwitchShiftUs  9.00us  stock 0.00us
    wow           tbWowNs            300ns   stock 0ns
  …

Each row carries the control's wire key beside its label, so a board read this way is a #set= link you can write without looking anything up. The text is on the page rather than only on the clipboard, because the page is where a browsing agent reads; the copy button beside the heading is for the other reader.

Every button says what it does in words. src/ui/buttonNames.test.ts fails the build on a button whose whole content is a glyph, because a screen reader and a browsing agent reach for the same thing — the accessible name. So clicking by name works across the panel, not only in the palette.

Single keys are verbs while the palette is closed: f fullscreen, c hold-to-compare, r record, s still, t fire, d drift, i cue, / search, ctrl+z undo.

Three things to know before trusting what comes back:

A worked sequence, all of it inside the browser: open the app, ctrl+k, type vhs, Enter. ctrl+k again, head switch 9, Enter. Take a screenshot, and the tear is there in the bars. The panel's preset chip now reads modified from "vhs", and the address bar carries every control the preset moved with the edit folded in — #set=demodMHz:0.5,lumaMHz:2.8,…,headSwitchShiftUs:9,…. Handing that link back is how an agent reports what it built, and pasting it into another tab rebuilds it exactly.

The address bar carries the board both ways: navigating to a link puts that look on screen, and turning a knob writes the change back into the bar. An agent that can navigate but cannot see the page still drives the whole instrument this way.

public/llms.txt is the contract — every parameter, with an example — and public/llms-full.txt names every control key, its range and the fault it models. Both are published (videoskillet.com/llms.txt), and both are generated by scripts/docgen.mjs from src/ui/controls.ts; a hand edit to either dies at the next pnpm docgen.

One command takes a picture of a link, for an agent working from a shell rather than a browser:

node scripts/shot.mjs 'http://localhost:5199/app/#preset=vhs&set=headSwitchShiftUs:9' out.png 6000

Four ways a link looks like it worked and did not:

On Linux, drive Firefox Nightly from a harness. Chrome's driver artifacts are harmless in the browser and unreadable in a log; scripts/shot.mjs already launches Firefox with the right prefs, so model a new harness on it.

Changing the code#

Read these, in this order:

filewhat it answers
CLAUDE.mdthe rules of this repo, short enough to read in full
ARCHITECTURE.mdthe premise, the pass graph, adding a control end to end
adr/the decisions where the obvious change is wrong
DEVELOPMENT.mdevery harness, and what each one cost to get right

Two sections earn a read before you touch anything they cover: the browser harness traps, before driving a browser at the app, and Measuring performance, before believing a number that came off this box.

Model the mechanism, not the artifact. There is no "VHS filter" here: dot crawl, tearing and hue drift emerge from a simulated signal path, so mechanisms interact with no code linking them together. A shader that draws the look of a fault matches one screenshot and loses every interaction.

Pick the domain before writing the effect. A horizontal displacement means one thing in the signal domain, another in sync, another in deflection, and routing a geometry fault through sync spins hue that should have stayed put. ARCHITECTURE.md maps the three.

decode stages a shared tile per row, so a horizontal offset has to be row-uniform. Per-pixel horizontal scaling needs that staging restructured first.

Never call device.destroy() on a device that has been presenting. Doing so ends the tab's rendering step, the tab still reports visible, nothing is logged, and the next document loaded there inherits the damage. The app lets go of devices instead and hands the live one on. ADR 0004 has the runs. A freeze showing frame 0 / STEP-DEAD / clock +0ms is that fault, not a bug in the signal path.

A control added to src/ui/controls.ts reaches every agent with no extra work — the palette indexes it by name and by its help prose, #set= takes its key, and pnpm docgen writes it into llms-full.txt. The schema is one table, so nothing else needs teaching about the control.

Several files in the tree are generated, and an edit to one survives until the next build:

generatedsourcecommand
EFFECTS.md, llms.txt, llms-full.txt, the loop block in FEATURES.mdsrc/ui/controls.tspnpm docgen
img/*-dark.svg, img/*-light.svgdocs/graphviz/*.dotpnpm docs
the demo bullets in README.mddemos.jsonpnpm demos

The gates, cheapest first: pnpm lint, pnpm typecheck, pnpm test, then pnpm harnesses 5199 for the browser sweep (six to nine minutes). CI deploys on lint and test.

Long runs belong in your own worktree#

This repo is worked in by several agents at once, and a dev server here serves whatever the tree currently says. Two consequences:

pnpm test excludes .claude/, where those worktrees live. Removing the exclude collects every worktree's suite into your run, so a half-finished branch somebody else is working on fails your tests, in files you have never opened, at paths that look like yours.

Leave the record where the next agent reads it#

An agent's context ends with its session, and the docs are what survives. A measurement that lives only in a transcript has to be made again.

What the AI actually did here#

Agents wrote most of the code, most of these docs, and the harnesses under scripts/. Fable one-shotted the initial signal-path design, including the signal-level premise the whole simulator rests on.

A person still picks the work, judges the picture — no harness can say whether a look is worth keeping — and owns the box the measurements come off. Every constraint on this page came out of something breaking on that box, which is why the docs read as a list of warnings: they are the part an agent cannot rederive from the source.