---
title: "What's new in Stims"
description: "Recent changes to Stims, with screenshots, plus how actively the project is being improved."
url: https://toil.fyi/whats-new/
source: https://github.com/zz-plant/stims/blob/main/CHANGELOG.md
---

# What's new in Stims

6 releases since 2024. Latest v1.5.0 on 2026-10-21. 67 changes in progress.

## Highlights

### A new look: the swatchbook (2026-11-10)

![A new look: the swatchbook](https://toil.fyi/screenshots/whats-new/swatchbook-landing.webp)

Every preset is now printed like a strip from a lighting-gel swatchbook: its catalog number, its name and maker, and a graph of bass, mid and treble with the sounds it reacts to lit. The chrome is matte black card that keeps one amber for whatever is on or playing and leaves every other colour on screen to the preset.

[Try it](/)

### The aesthetic compass (2026-11-08)

![The aesthetic compass](https://toil.fyi/screenshots/whats-new/aesthetic-compass.webp)

A draggable map of every previewed preset, projected from the same visual embeddings that power search. Drag from organic liquid to geometric wireframe, or from hypnotic and minimal to reactive and chaotic, and the nearest look plays.

[Try it](/?panel=browse)

### Pinch and twirl the stage (2026-11-08)

![Pinch and twirl the stage](https://toil.fyi/screenshots/whats-new/canvas-gestures.webp)

On a touchscreen, two fingers now steer the picture: pinch to zoom and twirl to rotate, starting from whatever the preset is already doing. One-finger swipes still change presets.

[Try it](/?preset=shifter-curlique)

### Sensory mood dials (2026-11-08)

![Sensory mood dials](https://toil.fyi/screenshots/whats-new/sensory-dials.webp)

Overall intensity, predictability, and a breathing pace. The dials lower the brightness ceiling, soften motion and contrast, smooth sudden jumps, and can breathe with you — and they can never raise the stage above the flash governor's limit.

[Try it](/?preset=shifter-curlique)

### Hands-free voice commands (2026-11-08)

![Hands-free voice commands](https://toil.fyi/screenshots/whats-new/voice-commands.webp)

Say "next", "calm", "breathe" or "full screen". Recognition runs on your device with the browser's local speech mode, and the switch only appears where the browser can keep audio from leaving it.

[Try it](/?preset=shifter-curlique)

## Full history

### Unreleased

#### Added

- The README links a 20-second clip with sound, recorded through the Record panel by `scripts/generate-sound-clip.ts`. It and the workflow MP4 are served from `toil.fyi/clips/`: GitHub downloads an MP4 this size from a repository link instead of playing it, so the README's old link never played.
- `?room=1` turns a visit into a display nobody has to operate, for a sensory room, a waiting area or a projector. It skips the launch page and plays to the demo track, or to the room's microphone with `&audio=mic`. Reduce flashing is on. Each preset stays up for `&dwell=` seconds (30–3600, default 180) and gives way to the next with an eight-second dissolve. The page holds a wake lock, keeps drawing when the window loses focus, and hides the controls, preset names, notices and pointer. Keys and taps on the stage do nothing, including blackout and white flash, and nothing is saved. `?brightness=` and `?motion=` (0–1) lower the comfort settings for one visit on any link and never raise them above what the device has saved. Settings → Playback and audio → Room display builds the link, and `docs/guides/sensory-room.md` covers kiosk start-up (#1596).
- **Publish a public video link…** publishes a four-second silent clip of the running preset, with its source code, and returns a `/s/<id>` page whose Open Graph tags carry the clip, so chat apps that read `og:video` show a playable card. The browser uploads quantized frames and the preset code, never audio. The Worker encodes a GIF and a JPEG poster, converts the GIF once to an H.264 MP4 through Cloudflare's Media binding, and serves the MP4 from R2 with byte ranges; only a record with a finished MP4 gets `og:video`. Uploads are same-origin, bounded in bytes, frames, dimensions and duration, and limited to three per IP a minute. Share pages are `noindex,follow`, the recipient runs the published code from an inert payload on the same origin, and the dialog hands back a token that deletes the record and every media file. The home page and `?preset=shifter-curlique` carry a curated clip (19,007 bytes at 192×108) (#1555, #1572).
- More actions → Share link has an **Embed** button, and the palette has Copy embed code (`copy-embed-code`). Both copy an `<iframe>` for the playing preset (`?embed=true&preset=<id>` on the page's own origin, titled with the preset and its author, 800×450 and responsive, `loading="lazy"`); before, embedding needed the `?embed=true` flag, which no screen mentioned. The snippet never carries a draft, and with unsaved edits the button says so. A preset made or imported in this browser has no embed, because no other browser can load its id. The oEmbed provider builds its `html` from the same code, so its iframe gains the responsive style, `fullscreen` and lazy loading (#1584).
- The editor's code names the sounds that drive each field: a line that sets an audio-driven field ends in its sources, as in `zoom=0.999514 ← bass, mid, treb`, read from the same dataflow analysis as the Tune chips. A field's literal line is tagged, and so is the last assignment to it in the per-frame or per-pixel equations; an assignment a later one overwrites is not. A newly loaded preset scrolls to the first tagged line (line 208 on the first-run preset). Before, only the Tune chips made this claim, and on a first open two of 45 were on screen at 1280×720 and none at 390×844. The tags are `aria-hidden`; the chips keep the accessible list (#1587).
- Find a preset has a **By reference** mode. Describe a look, and Stims ranks editable starting presets on the device with Browse's text scorer and the EmbeddingGemma 2 visual-term vectors; the description is not uploaded. An optional reference image lets Clef Omni pick among up to 12 of the matches, or decline. Open one in the editor and mark it accepted or rejected: the outcome goes to a local IndexedDB store (at most 100 episodes and 32 MiB, oldest evicted first), and later searches can reuse an accepted reference (#1551, #1552).
- Telemetry records which actions run, and from which surface: `action:<id>` from `dock`, `menu`, `palette`, `key`, `gesture`, `pad`, `landing`, `browse`, `editor`, `settings`, `cue` or `other`, each counted once per page load and at most 150 per load. The id is the command-palette id, so `E` and the menu's Edit preset code both report `action:open-editor`, and an id outside the app's own pattern is not sent. The beacon carries only the context fields every beacon already has, the start page says "Usage is counted as anonymous events — no account", and `telemetry:report` adds an actions-by-surface table (#1552, #1581).
- Telemetry beacons carry the OS family (`android`, `ios`, `windows`, `macos`, `linux` or `other`), the browser engine (`chromium`, `webkit`, `gecko` or `other`), the engine's major version, and a flag for an Android WebView. The browser works these out and sends only the classes: no OS version, device model or browser name. Crash beacons no longer include the raw user-agent string, which the Worker never stored. `telemetry:report` adds audible starts by OS and device, and by engine major version.
- The agent verb `edit-source` (`run('edit-source', { source })` or `{ append }`) changes the preset code the way typing does: the session turns dirty, the edit is saved as the preset's draft, and the leave-page guard sees it, where `set-field` writes only the running VM. The result carries the editor state (`dirty`, `errorCount`, diagnostics), and `bun run ctl` waits for the engine before running it (#1593).
- `bun run live` acts on the tab a person is watching. Under `bun run dev`, the dev server's hot-reload socket relays commands to the visible, focused tab and writes its events, state and last capture to `scratch/live/`; saving `scratch/live/preset.milk` applies it as preset source, and saving `pattern.js` plays it as a Strudel pattern. The channel answers loopback only, is off for servers the lab and e2e scripts start, and leaves nothing in a production build. `.mcp.json` registers the `stims` MCP server for Claude Code (#1574).
- Agent-readiness surfaces on toil.fyi: robots.txt carries Content-Signal usage preferences (search and ai-input allowed, ai-train not declared as allowed) in every crawler group; every `/learn/` page has a markdown sibling served for its `.md` URL and for `Accept: text/markdown`, with `Content-Location` and `Vary: Accept`; HTML responses advertise the machine-readable surfaces (llms.txt, sitemap, OpenAPI, api-catalog, MCP card, skills index) in a Link header; and `/.well-known/api-catalog` (RFC 9727 linkset), `/.well-known/mcp/server-card.json` (it states that no MCP endpoint is hosted on this origin, replacing a card that pointed at a dead `/mcp`), `/auth.md` (no authentication required) and `/.well-known/agent-skills/` index two SKILL.md files for driving and embedding the visualizer. `check:seo` guards all of it (#1599).
- `bun run test:budget` ranks gate and package tests by the CPU they burn, subprocesses included, and predicts each test's wall time at a target load (default 4 × cores), so its verdict does not depend on how busy the machine is during the run; it exits 1 when a test on bun's 5 s default would overrun below that load. Firefox and WebKit get a first-run smoke e2e: the landing, the note that tab and YouTube audio need Chrome or Edge, and demo audio going live on WebGL (#1591, #1593).
- The offline embedding script takes `--dtype` (`fp32|fp16|q4|q4f16|q8`, default `q8`), so a full re-embedding pass can run the model repo's lower-precision weights. The image index records the dtype, and a dtype change must regenerate every derived artifact in the same pass (#1539).
- New docs: `PRODUCT.md` (who Stims is for, its positioning, and what no surface may claim), `docs/UPSTREAM_CONTRIBUTIONS.md` (every upstream PR and proposal, with its status) and `docs/LEARNING_LOOPS_SPEC.md` (the nine model-assisted learning loops, with their stores, review, privacy and release gates) (#1550, #1570, #1588).
- Four ways to steer the visuals by feel instead of by parameter name. Settings → Accessibility → Sensory mood has three dials: grounding vs stimulation (a brightness ceiling, motion dampener and contrast scale that compose with the flash governor), harmonic predictability (smooths q-variable jumps once per frame), and a paced breathing guide (a 4–12 s expansion LFO). Two-finger pinch and twirl on the stage set `zoom` and `rot` from the preset's current base. Settings → Accessibility → Hands-free voice commands listens on-device (`processLocally`, hidden where the browser cannot keep audio local) for "next", "calm", "breathe", "full screen" and similar. Browse's aesthetic compass projects the shipped 256-d preview vectors onto a draggable 2D plane and plays the nearest preset on release, skipping presets Reduce flashing hides (#1679, #1681, #1682, #1683, #1684).

#### Changed

- The chrome is redesigned as a lighting-gel swatchbook. Every preset prints like a numbered strip of black card: its catalog number (from `catalog.json`'s `order`), name, maker, and a live graph of bass, mid and treble that lights the bands its drawn output reads, from the same dataflow analysis as the editor's sound tags. The landing is a riveted cover sheet over the running preset, the dock a strip with that graph along its top edge, the title card a swatch that swings in on its rivet, and Browse prints one catalog number per tile. Amber `#ffb21a` is the only colour the chrome emits and marks only what is on, selected, playing or focused; the primary action is a white tab. Pathway Extreme (width axis) and Atkinson Hyperlegible Mono replace Archivo and Martian Mono, the social cards and edge share card are set in the same faces, and the app icon is a swatch strip. `DESIGN.md` records the system.
- Preset pages link the topic hubs that list them, under **Collections**, and name those collections in their descriptions. Their structured data credits each hand in a chain as its own person, and their breadcrumb runs through `/presets/`. Every page's JSON-LD now names the same site, and identifies MilkDrop by its Wikipedia and Wikidata records; hubs add breadcrumbs and a preset list, the course hub is a `Course`, and the technique glossary is a `DefinedTermSet`.
- Reduce flashing is on by default for new visitors and for saved preferences that predate the setting; an opt-out you saved is kept. It used to start on only when the operating system asked for reduced motion. From a first visit, Browse, shuffle and autoplay leave out presets measured above the WCAG 2.3.1 limit, and the live governor dims detected strobing (#1573).
- The start page leads with your own audio. The main button reads Start visualizer (was Play demo), the source chips sit under Connect your audio (was Or play your own audio), the showcase is Jump into an iconic look, and a `?preset=` page says Starting visualizer… instead of Starting with demo audio… (#1614).
- The boot cover stays up until the launch page has painted, or 800 ms at most, so a slow connection no longer flashes the generic panel skeleton between the two. The starter catalog and the Archivo font preload with `fetchpriority="high"`, the loading cover and the launch hero keep clear of the iOS home indicator, the service worker (shell v11) precaches the four starter-showcase thumbnails, and starter cards name the preset they play to screen readers (#1603).
- A bare landing starts the decorative attract render on the first intent gesture (pointer, key, scroll, wheel or touch) or after a three-second grace period, whichever comes first, instead of during the first idle callback. The static signal field paints immediately and the page responds before the engine boots; in a Playwright run the first motion arrived 2.1 s after a scroll at 0.5 s and 4.9 s when nobody interacted. Five paired Lighthouse mobile trials put the median performance score at 85 against 79 for the idle-callback build (runs ranged 75–94 against 77–85, so this is recorded as a responsiveness change, not an FPS claim) (#1601).
- The notes under the start page's audio sources are one paragraph instead of up to four: the browser asks before Stims hears the microphone, a tab or YouTube, and sound is analysed in the browser and never uploaded. Firefox and Safari also get "Tab, system and YouTube audio need Chrome or Edge." In Settings, the comfort paragraph's two promises moved into their rows: brightness is a hard ceiling, and Reduce flashing is a mitigation, not a guarantee (#1585).
- About seventy controls say what they do before you press them. Side effects are named (Cast to a TV or Chromecast (starts a watch party), Reload (stops audio)); toggles show their state and flip their label (Save preset / Remove from saved); Pause reads Pause visuals, with "Sound keeps playing.", for the microphone, a tab and demo audio, and plain Pause for files and YouTube, which really stop; the cue deck's Take, Fade by hand and Skip are Play now, Play with a crossfade slider and Remove from queue; each AI control says "with AI"; and the dock's icon buttons show their name on hover and keyboard focus instead of in `title` tooltips that touch and keyboard users never saw (#1579).
- Destructive actions ask once, in place: Delete preset, deleting a saved version or set, Clear queue, Clear recent videos, the Performance reset, and deleting a public video link. The first press relabels the button (Delete for good), a second press within 4 s acts, and blur or Escape cancels. Each used to act on one press with no undo (#1579).
- In More actions, Generate with AI, Refine with AI, Edit preset code and Record video lead the second column (items 18–21 of 33, where they were the last four), so they show without scrolling at 1280×720. Preset sets…, which was palette-only, joins Presets and setlist, and Shortcuts and gestures, three levels deep on a phone, joins Workspace and system. The palette gains Black out the picture (`toggle-blackout`), so a MIDI pad can trigger the D blackout (#1580).
- The editor's code area follows the light and dark themes instead of staying dark, and its toolbar fits the 527px desktop rail on one row: Copy link is a toolbar button, Jump to variable… moved into ⋯ (⌘/Ctrl+J still opens it), and the reset button reads Revert. Escape leaves the code for the Problems toggle, so the next Tab moves on instead of indenting (#1587).
- Inline inputs take focus when they appear (the YouTube chip's field, Save current queue as a named set, a shortcut being rebound), and Escape cancels an inline edit without closing its dialog and returns focus to the button that opened it. ⌘/Ctrl+Enter submits the prompt in Refine, Generate and By reference. Browse search has a clear button and turns off autocorrect, autocapitalise and spellcheck, and the random sort gets a Reshuffle button (#1602).
- Search engines are offered only preset pages that show a visitor something. `lab:search-landing` loads each preset cold, the way a search arrival does (no gesture yet, so the demo audio is held), and records the larger lit-pixel share of two stage captures, at 5 s and 9 s, in `src/data/search-landing-evidence.json`. A preset under 0.5% lit pixels on either backend is marked `noindex` and left out of the sitemap, `/presets/` and `/api/feed`; the hubs and Browse still list it, and the page still plays. On the first pass 304 of the 1,787 measured presets landed blank, and `/presets/` lists 2,200 presets instead of 2,488 (#1594, `d478166bb`).
- The 18 presets the catalog rates high flash risk carry the swing of their strongest flash, so the flash governor primes each at 0.31 to 0.69, by how hard it flashes, instead of a fixed 0.35. All 21 high-rated presets were audited again at 60 and 30 fps; three now measure below the limit and keep their rating until repeat captures confirm the downgrade. `lab:flash-risk` reports its whole-frame figure as `maxMeanLuminanceDelta` (report version 2) (#1583).
- Two EEL rules follow the ns-eel2 source that MilkDrop 2 ships. `-2 ^ 2` is `-(2^2) = -4` on every tier (it was +4, the modern-EEL2 reading), and a `while()` condition counts as true only above the close factor (`|cond| > 1e-5`), so a condition that decays to 1e-6 ends the loop instead of running it to the iteration cap. No bundled preset writes `-a^b`, and all 558 `while()` statements in the corpus test a comparison or a logical result, so no bundled preset changes. All 82 `eel-conformance` cases are now pinned, and `packages/eel-conformance/docs/ns-eel2-source-audit.md` cites the file and construct behind each answer (#1567, #1595).
- Audio analysis allocates less per frame. Writing the audio texture the GPU reads takes 1.050 µs a frame instead of 2.921 µs (292.08 ms → 104.96 ms over 100,000 frames), because each pixel is one 32-bit write instead of four byte writes and three `Math` calls. The AudioWorklet reuses its six per-tick result objects and its transfer list, the main thread reuses its buffer lists for each worklet message, `getFrequencyFrame` returns one cached result per analyser instead of a new object every animation frame, and preset blends copy their typed arrays with `slice()`, about 35% faster in a benchmark (#1545, #1614).
- The audio analyser's FFT folds windowing, bit reversal and the first two radix-4 stages into one pass, harmonic/percussive separation takes its 7-frame and 9-bin medians through branchless selection networks, and the WebGL mesh transform computes its zoom and scale reciprocals once per frame unless the per-pixel code writes them (`d867a7941`).
- React Compiler 1.0 compiles the frontend through `react({ compiler: true })` in both Vite configs. Rewrites of the try/finally, dynamic-import and logical-assignment patterns that made whole files bail took it from 46 to 51 of the 59 files under `src/js/frontend/`; Browse, the stage dock and preset artwork followed, moving the strict census (every component and hook in a file compiles) from 48 of 61 to 51 of 62. Browse's virtualized list moved to its own uncompiled `BrowsePresetList.tsx`, and its height observer now re-attaches when the list empties and refills, where it could leave rows drawn over the lineage cards (#1539, #1543, #1577).
- The visual search vectors ship a gzip twin, `visual-vectors.bin.gz` (292 KB), which the loader decompresses with `DecompressionStream`, falling back to the raw file. The CDN serves the raw `application/octet-stream` binary uncompressed, so a first visual search downloaded 409 KB and now downloads 292 KB. `check:bundle-size` budgets the twin and checks that it round-trips to the raw file byte for byte (#1539).
- The WebGL and WebGPU composite passes read their shared uniform defaults from one contract, `src/js/milkdrop/feedback-composite-contract.ts`. A test fails when a shared uniform is bound on only one backend; before, a uniform added on one side read its neutral default on the other (#1547).
- Gamepad and TV-remote focus navigation and canvas video recording are two more standalone packages, `gamepad-navigation` and `canvas-video-exporter` under `packages/`, each with its own tests and demo site, and the app imports them by name. The recorder's two app ties, the presented stage copy it records from and the recording flag, are hooks it is handed by `src/js/frontend/engine/stage-video-exporter.ts`; outside Stims it records a cropped copy painted on animation frames. Behaviour in the app is unchanged (#1597).
- The three published packages carry their next versions: `milkdrop-toolchain` 0.2.0, `flash-guard` 0.2.0 and `eel-conformance` 0.1.1, with changelogs listing every change since 0.1.0. The prepared 0.1.1 entries for `milkdrop-toolchain` and `flash-guard` said "no code changes" while 27 and 8 of their source files had changed, so both take a minor bump instead. None is published yet: npm still serves 0.1.0 (#1576, #1589).
- The README's comparison with Butterchurn and projectM gains a Flashing safety row, a Perform with it bullet names MIDI learn, the cue monitor's crossfade and the projector window, and the 1,750-preset Butterchurn pack is told apart from the 1,787-preset catalog (#1571).
- The toolchain is on Bun 1.4.4 (`.bun-version`, `mise.toml`, `packageManager`, `engines.bun`). Bun 1.4 adds `bun audit fix`, `bun dedupe` and `bun prune` for dependency hygiene and `--cpu-prof-md` for profiling the measurement scripts. Workers Builds reads its Bun version from `packageManager`, so production builds moved too: Cloudflare's build images reject the compromised eMudhra intermediates, and Bun releases before 1.4.4 still trust them (#1539, #1542, #1575).
- Biome 2.6.2 formats Markdown, so tables across the docs and skills were reformatted (157 files, whitespace only) and `biome format` now touches `.md` files. `noInvalidGridAreas` is off because it rejects valid multiline `grid-template-areas`, and the one production hit of the new rules is fixed: the dock's `MenuHint` was defined inside its component and remounted every render (#1559, #1569, `8fbd9b7d5`).

#### Removed

- The `@codemirror/theme-one-dark` dependency, which the editor no longer uses (#1587).
- The `stims:shortcuts:open` and `stims:credits:open` window listeners, which nothing dispatched (#1580).
- The `stims:wide-gamut` storage flag and `core/wide-gamut.ts`. No screen set it, and on WebGPU it narrowed colours instead of widening them (pure red 241 came out 178), so the two backends disagreed. Output is sRGB on both backends, as it was by default.

#### Fixed

- A returning visitor who followed a preset link saw a "Welcome back" card for their last session while the linked preset started behind it, and Resume played the linked preset. The page now names the linked preset.
- The `/learn/` pages, `/presets/` and `/performance/` no longer load a Google Fonts stylesheet for two typefaces they do not use.
- The editor's rows above the code pushed the Tune tab and its first chip off screen at 1280×720, 390×844 and 1366×657. The first-visit hint is one sentence shorter, the describe field returns to its intended 32px under a mouse, and the share row gives way below 700px tall, where the toolbar still has Copy link.
- Six controls did something other than what they said. Closing the capture panel during a recording threw the video away; it is now saved. The editor's Clear snapshots dropped your edit while A/B showed the original; it is now Stop comparing A and B, and it restores the edit. Variations asked for three AI presets and showed one. Solo and Mute in the Outline kept parts hidden after the editor closed. A Browse card clicked before any audio started changed the route and nothing else; it now starts the demo with that preset. Browse's Show them turned Reduce flashing off everywhere; it now reads Turn off Reduce flashing (#1579).
- Editing on a phone held upright keeps the stage in view. The editor sheet is `min(76dvh, 40rem)` instead of 92dvh and the stage lifts by half the sheet's height, so at 390×844 204px of stage shows above the sheet (it was 68px), with the code and the Tune tab on screen. The stage is moved rather than resized, so its feedback carries on. The folded dock's Edit chip shows on phones again and opens the editor on the first tap (#1586).
- On phones: a phone held sideways (568×320) no longer opens scrolled 161px into the launch headline; Browse grid tiles stack the title above the byline instead of running it off the caption; the `/performance/` table stays inside its border when stacked; and the site-index footer's links meet the 24px tap-target minimum (WCAG 2.5.8) (#1615).
- In the light theme, the Shortcuts, Credits, Manage sets, Shared set and Video share dialogs and the Refine, Generate, Find a preset and audio-source panels follow the theme instead of carrying dark-only inline styles (#1602).
- Shared preset sets keep their order and any repeated presets when saved, and a second shared set can be saved after the first in the same visit. A set link no longer carries the sender's `#code=` draft or `?preset=`, which had also kept the recipient's import dialog from opening (#1573).
- Portrait (9:16) and square (1:1) recordings keep the picture's proportions; native capture widened only the camera's horizontal extent, so they clipped or stretched. A browser test decodes real recordings at 1080×1920 and 1080×1080 (#1549, #1573).
- Recordings carry the flash dimming the screen shows. The governor judged the stage brightness with the comfort ceiling included, so a flash the ceiling kept under the limit on screen went into the recording, which omits the ceiling, at full strength. It now judges the recorded picture (#1549).
- A recording with no audio keeps producing frames while its tab is hidden; its preview loop now runs on the worker frame timer instead of a suspended animation frame (#1549).
- A starter card on the start page plays its own preset; it started demo audio with the previous or default one (#1549).
- On WebGL, a preset's warp centre (`cx`, `cy`) sits where MilkDrop, Butterchurn and the WebGPU warp put it on a non-square screen. The CPU mesh placed it without the aspect correction, so on an upright phone or a wide window, presets that move the centre zoomed and rotated about the wrong point (#1590).
- On WebGPU, `rand(x)` in programs the GPU runs returns a value scaled by `x`, as it does on the CPU; it returned a unit draw whatever the argument. `randint(x)` is implemented on the per-vertex field path (`d867a7941`).
- Generate with AI on the hosted model no longer fails at random with "Model returned no output". `qwen3-30b-a3b-fp8` spent its default 2,000-token output budget on reasoning and left the answer empty; with an 8,192-token budget, the share of presets that compiled on the 20-prompt routing bench went from 40% to 100% (#1547).
- Check animation (Clef Omni) records one unbroken WebM. The first MediaRecorder a page creates can hand back an empty file while the encoder warms up, which kept the `decision-workflows` e2e (and main's CI) red; an empty recording is now retried up to three times, the recorder no longer cuts a chunk every 100 ms (at low frame rates that produced empty or corrupt chunks), and the render loop keeps full cadence while it records (#1544, #1545, #1546).
- `window.stims.agent` exists as soon as the app script runs. It was installed after the first paint, so a `ready()` call made once `#stims-main` appeared resolved at once and read the boot preset while a deep link was still loading; this was the intermittent `e2e-engine-mount` failure (#1582).
- The package publish workflow's typecheck and build steps ran nothing, because under Bun 1.4.3 `bun --cwd <dir> run <script>` prints usage and exits 0; the workflow and each package README use `bun --cwd=<dir>`. Each package's README examples pass in a fresh npm project on Node 22 and 24 (#1576).
- Three test flakes: the dock-menu click raced the bar's 3 s auto-hide, the flash-risk corpus test held port 5199 fixed, and the decision-workflows LFO wait was 10 s on software rasterizers (now 30 s) (#1568, `6cb60a736`).

#### Security

- Shared links are bounded before they are decoded. A `#code=z1~` link was inflated with no cap on the first render, so a short payload could expand to hundreds of megabytes, and the preset-set decoder capped memory but not time. Both reject compressed input longer than any link Stims writes (16,000 bytes) and cap the decoded size at 1 MiB of preset source and 4 KB for a set (#1548, #1598).
- One frame's programs share a single budget of 2,097,152 loop iterations: the per-frame program, every per-pixel vertex, and every wave sample and shape instance together. The cap used to apply per call, so a `while(1)` in per-pixel code ran it at every mesh vertex, about 25 s of main-thread work per frame from one opened share link. The heaviest bundled preset needs 80,281 iterations a frame (`martin-city-of-shadows`), so none reaches the budget (#1598).
- Preset equations also run under a wall-clock ceiling. A step that has spent 50 ms stops its loops and skips the remaining per-vertex and per-sample calls; init programs get 500 ms instead of an unlimited shared budget, and `getExecutionBudgetExceeded()` reports a truncated step. Compilation, shader work and geometry still have their own costs, and the live render path still runs on the main thread; the generated-preset reactivity probe compiles and steps untrusted source in a terminable worker with a timeout (#1600).
- **Breaking for embedding pages:** the iframe bridge accepts commands from another site only when that site is the direct parent of an explicit embed (`?embed=true` or an alias). From such a page, `toil:run` runs only 15 reviewed visual actions (preset navigation, `select-preset`, `set-field`, `crossfade`, parameter pins, the preset lock, autoplay, transition mode and length, `toggle-blackout`); device, clipboard and sharing actions are refused with a reason, and `toil:set_audio` still starts demo or microphone audio. Messages from an opener, a sibling, no sender or an opaque origin are ignored, and replies go to the requesting origin instead of `*`. Same-origin tools keep the full action API (#1598, `d478166bb`).
- Development dependencies clear the advisories that failed `bun audit --audit-level=high`: happy-dom 21.0.4 (GHSA-834v-8h66-3h79), the undici override at ^7.30.1 (GHSA-xjj6-f33q-929c), and zod 4.6.6 (GHSA-mrhx-c7xc-m3w5) (#1549, #1556).

### v1.5.0 — 2026-10-21

#### Added

- A Browse search made only of visual terms ("tunnel", "red fire", "ocean waves") also lists every preset whose preview looks like it. Presets whose title, author or tags name the term come first, then the rest in order of resemblance. The terms ship as EmbeddingGemma 2 vectors (`visual-terms.json`, 19 KB gzip) and are scored on the device against the preset preview vectors. Before, a term found only the presets the build had tagged with it, and two terms needed both tags on one preset: "tunnel" listed 7 presets and now lists 28; "red fire" listed none and now lists 6. A search with any other word is unchanged, and the preview vectors are fetched only for an all-visual search (#1529).
- `/presets/` lists every indexable preset, A–Z with its credit, and every collection and author page with its count. It is static HTML in the `/learn/` shell (`scripts/preset-index-page.ts`) (#1391).
- Each `/author/<slug>` and `/discover/<slug>` page lists every preset in its collection as links: in the raw HTML, written by the edge into `#app`, and below the stage once the app mounts (`CollectionPageDetails`). Long lists are grouped under letter headings with jump links. Before, the only list was Browse's, rendered client-side and virtualized, so a hub's raw HTML held no preset link and a rendering crawler saw 30 to 36 (#1391).
- Every page ends with a site index linking `/presets/` and every hub. The home page, the hubs and the preset pages carry it in their raw HTML, and the app renders it below the stage. It replaces a nav clipped to one pixel and marked `inert`, which listed six of the thirteen topic pages and no author page (#1391).
- A preset page links its `.milk` file ("Download .milk"), below the stage and in the `<noscript>` copy. `.milk` files carry `X-Robots-Tag: noindex`, so the page ranks for the preset rather than its source (#1391).
- Author pages for the 19 credited hands with 10 or more indexable presets and no page yet, from Royal (58) to ShadowHarlequin (10). A test fails when the catalog grows another (#1391).
- The Tune pane says which audio drives each control. When the preset's equations recompute a field, its chip names the signals that reach it (`eq · bass`, all of them in the tooltip and accessible name), or says no audio does. It reads the static dataflow analysis (`src/js/milkdrop/preset-dataflow.ts`) of the compile on stage. So it follows a signal through `q` variables, persistent state and per-pixel equations, works before any music plays, and reruns only when the equations change (`dataflowSignature()`), not on every fader move (#1329).
- A Tune fader whose field the per-frame code recomputes shows the value the frame used, as a tick on its track beside the base value the fader holds. The tick follows the running preset only while Tune is on screen and some fader has a tick. A field per-pixel code varies across the mesh gets none, since no single number is what was drawn (#1336).
- Outline rows say which audio reaches what each drawn part draws: the per-pixel equations and every enabled custom wave and shape (`bass +1`, or `no audio`). The answer comes from the same dataflow analysis as the Tune chips. A wave whose code reads no audio makes no claim, because its points sit on the waveform unless its code moves them (#1336).
- Solo and Mute in the Outline now cover the main waveform, the borders and the motion vectors as well as custom waves and shapes. Each layer gets a row under Settings when the preset draws it, at a visible base value or through an equation. Soloing any element now hides those layers too, so what remains is that element over the warped feedback (#1336).
- Each custom wave and shape has its own controls in Tune. Pick one in the pane's wave-or-shape picker, or press Tune on its Outline row. Its switches (draw, additive, thick, textured or spectrum and dots), colours and faders (sides, radius, position and angle; samples, scaling and smoothing) use the same widgets as the rest of the pane, and each move is a line in the draft. The `eq` chip on each control reads that slot's own code, so `x = …` in `shape_1_per_frame1` marks shape_1's X as recomputed. A field is found under whichever spelling the preset uses (`wavecode_0_bDrawThick` or `wavecode_0_thick`), and its own line is rewritten rather than a second one added (#1337).
- MCP tool `get_audio_reactivity_guide` (stdio and Worker) explains how presets read `bass`/`mid`/`treb`, their `_att` versions and `vol`, and names the `lab:dataflow` and `lab:reactivity` commands that check a preset. Given a `presetId`, it adds that preset's audio reach and measured reactivity from the catalog. `docs/ARCHITECTURE.md` and the authoring reference and listening chapter are now readable through `read_doc_section` (#1331).
- The iframe `postMessage` bridge answers truthfully. Every `toil:*` command gets one `toil:status` reply with `action`, `success` and a `reason` when it fails, echoes any `requestId`, and goes to the frame that asked. `toil:set_audio` and `toil:apply_tweak` do what they say (both replied success and did nothing), `toil:load_preset` replies once the stage shows the preset and refuses an unknown id with suggestions, and `toil:run` runs any palette action or agent verb. `bun run ctl -- --embed --post '<json>'` checks the protocol the way an embedding site sees it (#1328, #1332, #1334).
- The stage dock has an Edit this visual button beside the transport; the editor sat behind the More actions menu. First-time editors get one reversible example edit, a slow spin added after the preset's last equation. A Star Stims on GitHub note appears only after a successful share or video save, and stays dismissed once dismissed (#1342).
- Where Chrome offers WebGPU only in compatibility mode (an OpenGL ES backend), Stims uses it rather than falling back to WebGL. The GPU timestamp profiler and the compute VM stay off on that adapter (#1354).
- Browse cards and rows have a queue toggle beside the save heart, so the queue can line up presets that have not played yet; the dock and the palette could queue only the preset on stage (#1373).
- Four parts of Stims are standalone packages under `packages/`: `milkdrop-toolchain` (parser, EEL2 interpreter and JIT, IR, shader translation, formatter, MilkDrop 2 exporter, dataflow analysis), `flash-guard` (the WCAG 2.3.1 flash analysis and the live governor), `audio-reactive` (the AudioWorklet FFT, harmonic/percussive separation and beat tracking) and `eel-conformance` (the expression-language cases and a runner). The app imports them as Bun workspaces with no build step and no second copy of the code. Each has a read-only mirror repository and a demo site under `https://zz-plant.github.io/stims/`. `eel-conformance`, `flash-guard` and `milkdrop-toolchain` are published on npm; `audio-reactive` stays a workspace package (#1384, #1385, #1387, #1389, #1446).
- Share links carry remix lineage (`remix_of_N_id`, `_title`, `_author`), the same fields an exported `.milk` file carries, so credit survives a link as it survives a file. A source too long for a plain link is compressed (a `z1~` deflate-raw payload): every bundled preset now fits in a share link, where 16.8% did not. Links that fit before are byte-identical and old links decode as before (#1403).
- Generated presets are checked by the real compiler: `/api/validate-preset` runs `compileMilkdropPresetSource` instead of counting parentheses, with the same response shape. OpenRouter is a generation provider with a key you supply, sent from the browser to openrouter.ai and never through a Stims Worker. A generated preset that compiles but renders near-black is labelled low-confidence and offers Regenerate (#1404).
- The editor treats a preset's warp and composite shaders as their own blocks. Shader lines are highlighted, a shader that fails to compile reports its backend, program and stage instead of a bare fallback flag, and a Shader tab lists the blocks with jump-to-line, the compile errors (counted on the tab), and the GLSL the GPU runs (#1405).
- A watcher HUD (palette action `toggle-watcher-hud`) plots pinned variables over the stage, with the editor open or closed. Comparing two versions adds a summary of what the edit did to the audio reach, such as "zoom stopped listening to bass". A Textures tab lists the preset's texture bindings with a preview of each file it resolves to (#1406).
- The start page offers Edit this one beside Play demo and Browse presets once the preview is live; the folded dock keeps its own Edit button; the code gets at least eight lines above the dock; and a Tune chip names every band that drives its field (`eq · bass, mid, treb`), not only the first (#1406).
- Local audio takes several files at once, picked or dropped, and plays them as a playlist that advances at the end of each track and starts over after the last. Previous track and Next track appear in the audio panel and the dock's audio menu, the dock reads `File · 2/9`, and the system media keys and media controls follow the playlist. Palette actions: `next-audio-track`, `prev-audio-track` (#1443).
- `?reduceFlashing=1` turns Reduce flashing on for one visit, for a shared screen whose viewers never open Settings: shuffle and autoplay skip measured flashers and the live governor dims flashes. The setting saved on the device is unchanged, `?reduceFlashing=0` is ignored so no link can turn protection off, and the in-app toggle still wins (#1444).
- Two live stage keys for performing: D blacks out the picture while held, and two quick taps keep it black until the next press; Y flashes the screen white while held, so tapping strobes. Both change only what is shown, never the preset, its equations or the audio, and a notice beside the stage says how to bring the picture back. The shortcut list shows them under Live stage utilities (#1455).
- Nearby and the finder's By look tab rank presets by embeddings of their rendered previews (EmbeddingGemma 2, 256 int8 values per preset, fetched the first time a feature needs them). The old frame description gave most presets one of 81 descriptions, so most neighbours were ties. Browse can sort by Like your saved. 366 presets gained visual search terms ("tunnel", "galaxy", …), and Browse search now reads them: no catalog projection had passed `searchTerms` to the client, and "galaxy" now finds 12 presets instead of 3 (#1447).
- A per-preset frame-cost record, `src/data/preset-frame-cost.json`, filled by `lab:cost-ingest` from `perf:certification-corpus` runs and validated by `check:preset-cost`. On low-tier devices, autoplay and shuffle skip presets measured above 12 ms of frame work. The 43 presets measured so far ran on a high-end machine and none exceeds that budget, so nothing is skipped until low-tier measurements land (#1419).

#### Changed

- The start page paints its headline and tagline from the HTML instead of waiting for the app bundle. On a bare `/` with no saved session, the boot cover carries the first-visit header where the launch column draws it, so the crossfade to the app moves no line. Other arrivals keep the plain wordmark cover, because their header differs. In Lighthouse's mobile run of a local production build, the start page's largest contentful paint fell from 4.8 s to 2.7 s (#1392).
- A `?preset=` or `?audio=` arrival mounts the engine once the launch page has painted. The engine's chunks (about 300 kB gzipped on WebGL) used to start downloading alongside that page's own and slow it. `check:bundle-size` now also fails a build that puts three.js on the eager path (#1392).
- The sitemap, `/presets/`, the hub lists and the related links all read one catalog table, `public/preset-meta.json`, built from the root catalog and every library. The sitemap used to read the root catalog alone, so 144 library presets linked from preset pages were missing from it and 72 sitemap presets had no inbound link. It now lists 2,488 presets, each linked from `/presets/`; it listed 1,787 (#1391).
- 53 presets named only by a number ("11", "124") and the 37 projectM test fixtures ("000 Empty") stay playable and listed, but carry `noindex` and are left out of the sitemap. No catalog field names the numbered ones; their generated descriptions repeat ("dominant monochrome orange, smooth gradients, moderate motion") (#1391).
- A preset with the same name and credit as an earlier one names that one as canonical and is left out of the sitemap. 72 library presets repeat a root preset this way, re-encoded with a version header (#1391).
- Every public surface now tells the same story about what Stims is and what is new in it. [`docs/LINEAGE_AND_CREDITS.md`](./docs/LINEAGE_AND_CREDITS.md#what-stims-contributes) states the three contributions: per-pixel equations on the GPU, held to the CPU's answer by differential fuzzing; fidelity measured against native projectM; and the preset corpus analysed as programs. The README, both `llms` files and the comparison page repeat them. Running presets in a browser (Butterchurn was first) and editing them live (MilkDrop 2 had an editor) are no longer presented as new (#1327).
- `llms.txt` and `llms-full.txt` drop the "high-performance" and AI-first framing, the `?tweak=` flag and `toil:apply_tweak` message (neither does anything), and catalog fields that do not exist; the model-backed API routes are marked optional. The home screen's tagline names MilkDrop, the MCP endpoint stops calling itself "Stim Webtoys", and "MilkDrop-inspired" is gone from the docs (#1327).
- The compiler case study is deleted: its IR type, JIT output and WGSL kernel were invented, it described a dead-store pass that does not exist, and it credited the browser MilkDrop to a WebAssembly projectM port instead of Butterchurn. Its accurate parts are in [`docs/TECHNICAL_ACHIEVEMENTS.md`](./docs/TECHNICAL_ACHIEVEMENTS.md), which also now says the per-frame compute VM is off by default and documents the corpus analysis (#1327).
- The start page's tagline and explainer say what you can do with a preset: play it, see what moves it, and change it while it plays. `/learn/milkdrop-online/` drops "Winamp's Visualizer in Your Browser" from its title, which `docs/LINEAGE_AND_CREDITS.md` asks public copy to avoid, and the agent instructions under `.github/` drop "MilkDrop-inspired" (#1329).
- Docs pages no longer open by narrating themselves: sentences like "This document describes…" are removed where the title, outline, or a table's header row already says it (24 pages across `docs/` and `CONTRIBUTING.md`); where an opener mixed narration with substance, the substance stays. The rule is recorded in `docs/DOCS_MAINTENANCE.md` (#1343).
- Stims is set in Archivo and Martian Mono instead of IBM Plex. Each preset title on the stage gets its own width, from 62% to 112%, so it sets on one line, and wraps to two balanced lines only when it is too long fully condensed. The Learn pages load the same faces; they named a font they never loaded (#1358).
- One name per feature across the app (watch party, Find a preset, Demo audio), after a copy-edit of every user-visible string. The Learn course taught some wrong numbers: its smoothing examples barely reacted, the Track 3 beat flash never fired, and `sin(rad*12)` draws about two rings, not twelve. Those lessons are corrected (#1356, #1367).
- The first paint downloads 585 kB of JavaScript instead of 1,007 kB: the CodeMirror editor and `three/webgpu` no longer ride the boot path, and WebGL sessions never download `three/webgpu`. `check:bundle-size` fails a build that puts either back (#1357).
- WebGL batches shapes and borders as WebGPU does, which halves frame time on shape-heavy presets (`martin-the-bridge-of-khazad-dum` 4.49 → 2.30 ms on an M1 Max). Overlapping shapes of different kinds can draw in a different order, as they already did on WebGPU (#1355).
- The start page leads with what Stims is ("A music visualizer you can open up"), with Stims as a nameplate. Audio sources are one chip row, Browse presets is the same secondary button for first-time and returning visitors, and the demo note sits under Play demo. After Play, the first hint waits for the title card to clear, and the audio-match suggestion waits 12 s and is never offered for demo audio. Curated first now weighs how readable each preset's preview is, so it no longer opens with near-black thumbnails (#1368).
- The first-run preset is `shifter-curlique`. Its predecessor, `krash-rovastar-cerebral-demons-stars`, hard-codes a 16:9 warp centre, so on a phone held upright its stage settled to black on WebGPU. First-run evidence is now recorded at 1280×720 and 390×844 on both backends (#1376).
- Flash samples are read back in a worker, so Reduce flashing no longer holds the main thread for 2–3.6 ms a frame waiting on the GPU (#1375).
- Telemetry beacons carry coarse context: screen orientation, device class (phone, tablet or desktop), the live audio source, and an arrival class (search, assistant, social, internal, other or none) read from the referrer's hostname. The URL, its query and the hostname itself are never sent. Automated browsers (`navigator.webdriver`) no longer post telemetry (#1377, #1381).
- Preset switches on WebGPU compile the incoming preset's pipelines before the blend starts, holding the fade for up to 1.5 s while the old preset keeps playing. The 0.25–1 s freezes at a switch are gone; a 50–75 ms hitch mid-blend remains (#1422).
- Less CPU per frame: the JIT advances the EEL random generator inline and reads a variable used twice in one statement once (median frame work on `shifter-glassworms-flare` 5.24 → 4.51 ms), the per-vertex loop skips built-ins the per-pixel program never references (`eos-apocalypse` 4.14 → 3.77 ms), presets with an identity warp skip the mesh transform, and the preset fading out of a crossfade runs at 75% detail (#1420, #1421, #1424).
- Author pages say "MilkDrop presets credited to Royal. Each one plays live in your browser." instead of attaching "playing live" to the author's name, and the `/presets/` intro leads with the same phrase rather than trailing it after a list (#1395).

#### Removed

- `/discover/retro`. Its search matched no preset's title, author, id or tags, so the page and its Browse list were empty; it redirects to `/presets/` (#1391).
- **Breaking for MCP clients:** the tools left over from the toy library. They described a one-entry registry and a loader that no longer exists. Calling one now returns an unknown-tool error (#1331). Replacements:
- `src/data/toys.json`, which nothing reads once those tools are gone. The sitemap's MilkDrop image title is now a constant in `scripts/generate-seo.ts`, and the Vite build no longer derives inputs from it (#1331).

#### Fixed

- A shared preset's social card (`/api/og-preset`) is now a JPEG under 300KB: 96KB median across a quarter of the corpus, 296KB at worst. It was resvg's truecolor PNG, 775KB to 789KB for the presets checked in production, past the size at which WhatsApp, among others, drops the preview image. A 256-colour palette PNG, which keeps the static cards under the same limit, fit only by cutting the busiest 9% of frames to 64 or 32 colours, which darkened them visibly. Preset pages drop the shell's `og:image:type` (`image/png`), since the card URL answers with a JPEG or, when rendering fails, the static PNG; cards cached as PNG are not served again (#1485).
- On desktops with classic scrollbars, the launch column no longer slides sideways when the boot cover hands off to the app. The launch page scrolls, so its scrollbar appeared when React mounted while the cover had none: the column moved 7.5px at 1440px and the tagline lost 15px of measure at 412px (3px and 6px where the 6px custom scrollbar applies). The cover now reserves the same gutter, and the home view keeps base.css's stable gutter; live mode and embeds still drop it. `tests/e2e/chrome-visual-contract.test.ts` checks the hand-off with classic scrollbars as well as hidden ones (#1394).
- A `?preset=` page keeps the preset's name and "Starting with demo audio…" on screen until the session it started ends. It dropped to the generic first-visit pitch on the first render after auto-start, so the pitch showed during the engine boot and again under the stage's fade-in. That late paint was the page's largest contentful paint: 7.8 s in Lighthouse's mobile run, 5.5 s now (#1392).
- The crawl copy in the raw HTML's `#app`, the site index footer and a hub's preset list, is not laid out while the boot cover is up. React replaces it on mount, but under the cover it reflowed when the deferred stylesheets landed: a layout shift of 0.2–0.5 on the desktop home page and 0.24 on a hub page that no one could see (#1392).
- The artwork in a `?preset=` page's header no longer pushes the buttons and the demo note down when the catalog resolves the preset. Its box is held from the first render, which takes the page's layout shift from 0.09 to 0 (#1392).
- A `?preset=` page names its preset. Below the stage, in normal flow, it shows the preset's name as the page's one h1, its author (linked to the author page where one exists), more presets by the same author, and the topic hubs. Search engines render the page and skip `<noscript>`, which held the only copy of this content, so the sitemap's preset URLs read as one page under the heading "Stims visualizer". The edge now writes its no-JavaScript copy into one `<noscript>` instead of all four in the shell, and both copies come from `functions/shared/preset-page.ts` (#1388).
- `/author/<slug>` and `/discover/<slug>` pages name their collection in the h1 ("Geiss MilkDrop Presets") instead of the home page's headline, for returning visitors too (#1388).
- Presets in Browse (grid tiles, list rows, the recently-played rail and the remix family) are links to their `?preset=` pages, so crawlers on the hub pages can follow them and a preset opens in a new tab with a modifier click. A plain click still switches presets in place (#1390).
- A preset credited to a chain such as "Stahlregen + Geiss" links each hand in its byline to that hand's author page. Its related presets come in one group per hand, drawn from the presets crediting that hand, the rule the author pages already use (#1390).
- Stims boots where reading `localStorage` throws: sandboxed iframes, third-party iframes under an opaque-origin parent (which covers the `?embed=true` player), and browsers that block site data. It used to stop at "Stims crashed" (#1330).
- A ramp taken over by a later ramp ends at once with `landing: 'superseded'` and names the targets it lost, instead of running out its duration and reporting values it never reached. MCP `session_ramp`, macros and scene recall move on accordingly (#1362).
- The skip link on `/certify/` appears on keyboard focus; its reveal rule was invalid inline CSS (#1363).
- Editing and comparing no longer overwrite work. Undo history restarts on each preset load (undo on a fresh editor used to empty the code). A/B compares your edit with the original rather than with itself. Reset is now Revert to the original, and it can be undone. Drafts are saved only for real edits, so later fixes to a `.milk` file reach visitors who had opened it. Refine shows a diff with Apply and Discard instead of replacing the running preset (#1369).
- Reduce flashing acts in production. The flash sampler read the canvas outside the task that drew it, so it saw a blank frame on WebGPU and a dimmer one on WebGL, and the governor never engaged outside agent mode. It now samples each frame as it is drawn, at a 60 Hz cadence; shuffle and autoplay skip measured flashers when it is on; the stage says "Dimming flashes" while it dims; and the setting is also in the dock menu and the command palette (#1370).
- The flash governor counts what the WCAG audit counts. It measures each tile instead of one bilinear sample, no longer reads its own dimming as content, and judges frames it skipped correctly. On the first-run preset both now count no flashes, so the stage stays at full brightness in silence (#1383).
- Links to presets you made or imported carry their code (`#code=`), so the recipient no longer gets "could not be loaded", and the receiving page imports the code as a preset of its own. Exported remixes record their parents (`remix_of_N_id`, `_title`, `_author`), and import reads them back (#1371).
- A MIDI fader bound to `crossfade` drives the cue deck's hand fade, DJ-style, instead of writing a `crossfade=` line into the preset. Knob moves write the running preset live and commit to the source once the control rests for 250 ms; every message used to recompile. Learned controls span each target's usable range instead of 0–1, and touching a Perform fader or the on-stage crossfade picks it as the learn target, as the Settings hint promised (#1372).
- Browse's Best match sort ranks by the search; it fell through to the unsorted order. "High fidelity first" is gone, since every bundled preset reported the same value for it (#1373).
- `/discover/<slug>` and `/author/<slug>` keep Browse open on their collection; an idle effect played the featured preset and closed the panel a second after it opened. A bare `/` stays `/` instead of becoming `/?preset=shifter-curlique`, and low-power and reduced-motion visitors keep the static landing (#1380).
- The stage names presets as their authors wrote them. A trailing `- <Name> Tweak` is read as the edit credit, so "Eo.S.+Phat Emergent factors - Bitcore Tweak" no longer shows as "Bitcore Tweak", and a title that opens with its own author chain drops the repeat (36 titles and 2 bylines in the catalog change). Filename underscores show as spaces. On phones, the title's shadow no longer clips into dark slabs, a two-line title drops one size, and at 480px and below the dock stacks the title over the byline (#1393).
- A shared `#code=` link waits for its preset to load before restoring the edited source. It could compile against a fallback preset while the requested one was loading and then lose the restored source or the hash (#1407).
- The boot cover and the home hero reserve the same thin scrollbar gutter, so the tagline keeps its width when the app takes over; at 412px with classic scrollbars it narrowed from 369px to 359px (#1416).
- More preset shaders compile on WebGL. Native bodies have HLSL `saturate`, `frac`, `lerp` and `atan2` translated (65 shader stages reached GLSL with them) and assignments wrapped in the target's type, as HLSL broadcasts and truncates; comma-list and `static` declarations above `shader_body` carry over; a split `float3 dx, dy;` no longer turns `dx` into the motion control; and the warp shader declares the `r`/`g`/`b` and `hue_shader` colour uniforms it reads (#1448).

### v1.4.0 — 2026-10-01

#### Added

- Pushing a `vX.Y.Z` tag publishes a GitHub Release: `.github/workflows/release.yml` runs `bun run release:notes`, which takes that version's section of `docs/RELEASE_NOTES.md` and refuses a tag that disagrees with `package.json` or has no notes. Versions 1.1.0 through 1.3.0 were written up but never tagged, so none of them appeared on the repo page (#1324).
- Pause. Space and the dock's transport button hold the picture and release it; the preset, history and audio session stay put. Stopping audio — which unmounts the engine and returns to the start page — moves to the menu as "Stop audio and go back to start". `EngineSnapshot.playbackPaused` and `__stims_agent.getState().playbackPaused` expose the state; `toggle-playback` is the palette action (#1207).
- The preset-tuning keys are listed, rebindable and announced: H (blend/cut), W / Shift+W (waveform), I / Shift+I (zoom), O / Shift+O (warp), J / Shift+J (wave scale), `<` / `>` (rotation) are registry bindings dispatching `nudge-*`, `wave-mode-*` and `toggle-transition-mode` palette actions (`frontend/preset-nudges.ts`), each reporting the value it landed on. They replace the MilkDrop runtime's document-level key handler (`ui-bridge.ts`), a leftover of the standalone overlay that in the shell was undocumented and reported only to the agent debug snapshot. Q (echo zoom) is not carried over — the canvas holds Q as a performance key — and R, which duplicated N, is free; Backspace stays as an alias of Previous (#1207).
- Quick-select shows its numbers: the first nine cards in Browse wear the digit that plays them (and `aria-keyshortcuts`), the shortcut reads the same list the panel is showing, and with Browse closed the digits say to open it instead of playing an unpredictable preset (#1207).
- Virtual time for deterministic visual captures: `animation-loop.ts` exposes a controllable `virtualTimeSource`, and the capture script plus visual-regression e2e drive it so single-frame comparisons stop drifting on phase (`ca5a4f4b1`).
- WebGPU timestamp profiler and temporal reconstruction (`442ec2c49`).
- Hermite-spline audio reactivity interpolation (`8ad6f2a1a`), continuous dynamic-resolution scaling with an accumulator, and CAS sharpening (`57d591f44`).
- Audio search now feeds real spectral bands, and presets are described by how they look rather than what they are called (`eba211d8c`, `7e57c0ad2`).
- During a crossfade the outgoing preset keeps running: it steps its equations and renders its own warp, composite shader and feedback on a second deck while the incoming preset dissolves in over it, as projectM does. That happens when twice the current frame still fits the budget and the device is not warming; otherwise the switch blends from a snapshot as before. The manual crossfader uses the same decks, and a blend that the per-frame gates suspend for over a second is cancelled instead of staying in `blending` (#1325).
- The Release workflow can be run from the Actions tab on `main`, for anyone who cannot push tags: it tags a commit on `main` (by default its head) as `v<that commit's package.json version>` and publishes the same notes a tag push would. It refuses a commit that is not on `main`, or a tag that already points at another commit. (#1326)
- Verification tiers between unit tests and a browser. `lab:nan-sweep` compiles every bundled preset and steps its VM for 120 frames, flagging compile errors, step errors and non-finite output (2,679 presets in about 10 minutes, no browser); `lab:replay` records a VM trace and replays it bit for bit, naming the first frame and variable that diverge; `trace:butterchurn` diffs per-frame variables against Butterchurn; `lab:backend-diff` scores WebGL against native WebGPU against each backend's own run-to-run noise; and `lab:glsl-corpus-scan` validates the assembled WebGL shaders offline with glslangValidator (`22af8bdc8`, `9640d58e3`, `b8aa2452f`, `505d38772`, `f33d3f242`).
- `lab:dataflow` reads, from a preset's compiled equations, which audio signals can reach each per-frame variable, following the VM's reset, persistence, shared-`rand()` and cross-program `q`/megabuf rules. Checked against traces of 2,445 held-out presets, 1 of 215,160 cells varied with the song where it found no audio path. The catalog's quality score and the `collection:audio-reactive` tag read it instead of searching preset text for audio names; the tag moves from 1,391 to 1,477 presets (+361, −275) (#1303, #1307).
- A runtime flash governor applies the WCAG 2.3.1 rule the offline audit checks, with the same threshold code: when the presented frame would flash, it holds part of the previous one, and presets the catalog measured above the limit start clamped. The audit gained the red-flash criterion. On the two flagged presets it was validated against, flashes went from 4 to 2 a second and from 8 to 3. Until 1.5.0 its sampler read the canvas outside the task that drew it, so it did not engage outside agent mode (#1116, #1141, #1145).
- Live performance: a cue deck shows the next queued preset running, with Take, Skip and a hand-driven crossfade; autoplay waits for a downbeat (4 s, unquantized at 8 s); pinned faders say when the preset overwrites them; live performance mode (Shift+D) holds the quality step and overrides battery frame-halving; MIDI gains notes, clock in at 24 PPQN, clock out and output to controller LEDs; and a webcam, a second display and a gamepad are available as inputs (#1118, `e64a1f908`, `68758cfd1`).
- Editor: an Inspect tab with live q1–q32 and every equation variable, with sparklines and pins; Reference, Compat and Outline tabs; named versions that survive a reload and can be compared; preset-parameter knobs (on 263 of 2,686 presets); a GLSL view for each shader; `.zip` pack import; solo and mute for each wave and shape; and MilkDrop 2 export. Format is lossless: opening a preset used to delete its shader comments (544 of 579 shader presets), flatten each shader onto one line and drop equation comments (634 presets), and init code was written as `init_N`, which MilkDrop 2 and projectM ignore (#1243, #1251, #1252, #1253, #1254, #1255).
- The cream-of-the-crop library grows from 40 to 895 presets through a gated ingest (clean compile, supported on both backends, no unshipped textures, content-hash dedupe), and near-duplicate clustering and a measured quality score feed `curatedRank` (`90de5b4f0`).
- Presets can read harmonic and percussive energy (`harmonic`, `percussive`, `percussive_low/mid/high`, `percussive_ratio`) from a median-filter split of the spectrum that costs 0.021 ms a frame for 512 bins. It separates sustained tones from transients, not instruments (`66ef9e13f`).

#### Changed

- One public pitch, "Play and live-edit MilkDrop presets in your browser", replaces "MilkDrop-inspired" in the page title, social and oEmbed metadata, shared-preset link previews, the share sheet text and the sitemap. It says what Stims does with the presets (they are the original `.milk` files) without claiming to be MilkDrop, and it drops "Winamp's MilkDrop, in your browser", which `docs/LINEAGE_AND_CREDITS.md` already asked public copy to avoid (#1322).
- The README is less than half its old length: the pitch, four larger clips, a short comparison with the compatibility numbers a CI test pins, how the code is laid out, and how to start. The system diagram and the engineering detail it carried moved to `docs/TECHNICAL_ACHIEVEMENTS.md`; the frame-cost benchmark was already in `docs/RUNTIME_PERFORMANCE.md` (#1322).
- Removed 130 MiB from the working tree: `bin/stims-ctl` (the 60 MiB `ctl:compile` binary, now ignored), `output/` (60 MiB; already ignored, but 197 files were committed anyway), the four README clips the page no longer shows, and four unreferenced leftovers (`audit-input-blocking.ts`, `src/stims-demo.gif`, `test-screenshots/`, a `.sisyphus/` session file) (#1322).
- Stage dock: the bar stays up while a pointer rests on it or focus is inside it (it faded out from under the cursor after three still seconds), and the "Controls" reveal handle no longer paints over the title while focus holds the bar. The transition control opens the ladder as a popover and prints the engine's actual value instead of cycling four states and rounding to the nearest rung. The overflow menu is two columns from 640px up, with Settings and the command palette leading their group — as one column of 26 items it scrolled at every common desktop height with those two last. On touch the primary Prev/Next buttons are 44px like everything else (a specificity bug held them at 38), and at ≤480px the bar drops Save and Full screen so the preset title gets ~150px rather than "K.."; Save gains a menu row and both keep their stage gestures (#1207).
- Scrolling over the stage only nudges the visuals; changing the preset by scroll now needs Shift, since a trackpad flick with momentum cleared the old 120px threshold on its own (#1207).
- The first-run hint names Space and `?`; the drag hint fires on the first drag of any preset rather than on presets that read interaction signals (0 of the bundled catalog) (#1207).
- `mat2` element writes in a native `shader_body` now execute directly on WebGPU: the analysis gate that sent every matrix element write to the uniform-only approximation is narrowed to `mat3`/`mat4`, the only sizes the node executor cannot represent. 57 bundled presets move off the approximation (WebGPU shader-translation gap 226 → 169; fully supported on both backends 1521 → 1577) (#1166).
- `mat3`/`mat4` element writes execute directly on WebGPU as well: the node executor carries a mat3/mat4 as its column vectors, so `M[int(0)].x = q20` is a column swizzle and `(p / q7) * M`, `M * v`, `M * M`, `mul(...)` and `transpose(...)` are spelled out column-major. Shader analysis seeds every bare `matN` declaration with `matN(0.0)` so the executor knows the size before the first element write, and the analysis gate now covers only writes at a runtime index (the corpus has none). Runtime-index matrix and vector reads (`mat4(...)[int(mod(p.y, 4.0))][int(mod(p.x, 4.0))]`) go through element access instead of dropping the statement, initialized matrix declarations (`mat3 m = mat3(1.0)`) parse as the assignment they carry, and a compound element write (`M[0] += v`) reads the previous column out of the stored matrix. The branch desugar masks indexed targets too, so a mat2 column write under an `if` no longer sends the whole body to the approximation. With shipped defaults the WebGPU shader-translation gap moves 169 → 168 (19 of the 20 mat3 presets also branch), and 1,578 of the 1,750 Butterchurn presets run with no approximation on both backends; with `shaderBranchDesugar` on it moves 50 → 14 and fully-supported 1693 → 1729 (#1167).
- The WebGPU node executor now binds MilkDrop per-frame registers a shader body reads without assigning (`tele`, `hordist`, `blur1_min`, …) as uniforms driven from the VM frame state, as the WebGL path already did with `uniform float` declarations. Reads of such names used to compile to nothing and silently dropped the statement and everything downstream of it (8 bundled presets, 3 of them among the `mat2` bodies above) (#1166).
- Bounded the growth paths the #1105–#1111 series found: compiled-preset cache warmup (#1109), the preset preview cache (#1108), idle renderer pool retention (#1107), source-diff memory (#1110), and offscreen shader identicons, which now pause (#1111).
- Moving the pointer over the stage schedules one auto-hide deadline per animation frame instead of a timeout per event (#1106), and a preset change animates only the stage's opacity and transform: the live canvas is no longer blurred or promoted on a `filter` layer, and reduced motion turns the transition off (#1105).
- On WebGPU, per-pixel equations run on the GPU for 1,611 of the 1,619 bundled presets that have them. The planner lowers q1–q32 (was q1–q8), `aspectx/y`, `pixelsx/y`, `meshx/y`, assignable `pi`/`e` and `log10`; any scalar the per-frame code assigns travels on a 32-slot variable bus; read-before-write locals are zero-initialized, as in NS-EEL; and per-frame `q` registers and extended shader intrinsics reach GPU field programs. The eight presets left on the CPU are six `randint` callers, one `gmegabuf` reader and one nested-assignment side effect (`9a8b78794`, `f9573e76b`, `f8156c384`, `88df79354`, `58f985f78`).
- The CPU mesh transform builds its per-frame constants once per pass instead of once for each of ~1,764 vertices, presets with no per-pixel program skip the per-vertex environment, and a `q`-seeding loop that could never run (56,448 no-op `in` checks a frame) is deleted. CPU-isolated `vm.step()`: `cope-ferrofluid` 1.06 → 0.35 ms, `geiss-casino` 0.95 → 0.39 ms, `shifter-robotopia` 2.45 → 1.19 ms (p95 8.42 → 1.84 ms); on the per-pixel path the five measured presets got 2.7–7.5% faster (`ef60340cd`, `9565604d6`).
- Shape-heavy presets draw in a fraction of the time. Flat transparent double-sided materials render in one pass instead of two (the second pass re-hashed every material's program key, 50% of WebGL CPU on a ~786-fill preset), and native WebGPU gets the instanced shape and border batcher as TSL materials instead of drawing ~1,570 objects one by one. On `martin-the-bridge-of-khazad-dum` the average frame (live rAF) went from 33.8 to 8.6 ms on WebGL and from 8.98 to 3.05 ms on WebGPU (`0ba121f02`, `ae1ead136`).
- The JIT skips duplicate stores when a caller passes one object as both environment and local scope: `eos-apocalypse` average frame work went 3.43 → 2.87 ms at 1× and 17.56 → 15.31 ms at 4× CPU throttle (Apple M1 Max, native WebGPU, quality step 2 locked). The ~300-property shader-control environment is reused across frames: `geiss-game-of-life` on WebGPU 2.93 → 2.30 ms (`9a4b789b6`, `a3b9cbf16`).
- Preset switches stop stalling the picture on phones. Loads compile in a worker, every equation block is JIT-compiled before the swap, autoplay prefetches and compiles its next pick 8 s early, and WebGPU compiles the incoming warp and composite pipelines with `compileAsync` before swapping them in. On a Galaxy S22 most switches drop no frames, where about half stalled for 300 ms–2 s, and the 3.4–3.9 s worst case became sub-500 ms single gaps on most switches (worst outlier 1.9 s) (`207158aaf`, `fb736649d`, `4114d1694`).
- Chrome for Android gets WebGPU (a desktop-only check had excluded every phone), 8-core phones no longer classify as the top tier, and capable phones get a 2.0 pixel-ratio ceiling on WebGPU (1.2 on WebGL). A Galaxy S22 had been rendering at ~43% of native resolution using 7.9 ms of its 16.7 ms budget; at 1.75–2.0× it held 60 fps at ~8 ms a frame over 5 minutes (`79505b9d1`, `17f39b337`).
- Startup and deploys are lighter: the Strudel lab's dependencies leave the eager vendor chunk (690 KB / 228 KB gzipped → 38 KB / 12 KB), the catalog parses in a worker, `dist/` goes from 269 MB in 4,626 files to 42 MB in 2,843, and the service worker's refresh revalidates at the origin, so the HTTP cache no longer hides a deploy for up to an hour. Median cold `shell-rendered` went from 2,288.4 to 2,120.7 ms (4× CPU throttle, 150 ms latency, 200,000 B/s) (`1f0b4d636`, `c2e4597a6`, `b28581eb8`).
- The projectM parity oracle measures what it claims. References are re-rendered against audio the presets react to, and the certified pass count drops from 4/9 to 2/9, because three of the four passes were against frames a solid-black image also passed; `parity:noise` measures each preset's run-to-run spread, and the suite grades against that band; four bright, frame-stable references were certified; and the capture suite reuses one browser (32.8 s for the nine certified presets, was 111.5 s) (`575f13ca3`, `77bfa06db`, `be93d0a6d`).
- The compatibility classifier reports real gaps. It had reported `exact` for all 1,750 bundled Butterchurn presets because its gap tables were empty; it now reports which presets approximate a shader on WebGPU and which use an identifier the VM evaluates to 0, pinned in both directions by `butterchurn-corpus-support.test.ts` (#1116).

#### Removed

- The toy plugin layer and the JSON → schema → generator → manifest → checker pipeline that managed its registry of one entry (`check:toys`, `generate:toys`), with five docs describing a plugin system that was never one. Nothing removed was reachable from the app entry point, so the bundle is unchanged (#1104).

#### Fixed

- The interpreter, JIT, WebGPU field lowering and compute VM compute the same values, and seeded differential fuzzers keep them that way. Their first runs found 18 blocks in about ten shipped presets that could not JIT at all (the census after: 38,406 blocks, 0 failing) and 308 of 955 lowered per-pixel programs (32%) that gave a different answer on WebGPU than on WebGL (after: 955 of 955 agree), fixed with truncating `%` and `int()`, guarded log/pow/sqrt, zero-guarded division and a 0.00001 close factor. WGSL `pow()` returned NaN for every base ≤ 0; 300 of 300 programs now agree on a real GPU. `pi`/`e` are assignable on every tier, and where a Content Security Policy forbids `new Function`, an interpreter executor with the JIT's store contract runs instead (`ba4d5bdfa`, `95f50ed4a`, `ef78a0603`, `b28ce87c5`, `f1cca2dfa`).
- Presets without a warp shader animate through the feedback loop. Their per-frame `zoom`/`rot`/`dx`/`dy` never reached the feedback pass, and the blend mixed the previous frame in by `fVideoEchoAlpha`, so a preset with no video echo threw its history away every frame: `zoom = 1.10` drew one static circle instead of expanding rings. Both backends now composite the decayed history over the new frame, and WebGL warps the previous frame by the VM mesh when a preset writes warp variables per pixel. On WebGL, `rovastar-parallel-universe` went from 97.23% to 85.31% mismatch against projectM (`1c36f3ec7`, `830d03380`, `2c4c042e9`).
- Native WebGPU draws what WebGL draws. Its own render targets are read the right way up (the display was mirrored and feedback came back flipped each frame: `krash-rovastar-cerebral-demons-stars` 93.91% → 82.19% mismatch, `250-wavecode` 1.02% → 0.49%, a pass), presets without shaders are no longer ACES tone-mapped (`100-square` 16.57% → 1.65%), and division keeps the divisor's sign and lets NaN through (`a3d898849`, `cdf519e64`, `d83b029a7`).
- WebGL compiles far more preset shaders. The offline scan of 2,679 presets went from 575 failing presets (975 stage errors, which three.js had swallowed) to 150 (162 errors): authors' scratch variables are declared as locals instead of read-only uniforms; MilkDrop 2's `GetPixel` and `GetBlur1`–`GetBlur3` helpers exist (511 presets called them and rendered black); HLSL scalar promotion and truncation, mixed-width arithmetic, `mul()` operand order and `floatNxN` are translated; and the feedback frame clamps to [0, 1] like MilkDrop's 8-bit buffer (`6ee742723`, `68758cfd1`, #1220, #1221, #1223, #1227, #1228, #1229).
- Preset switches crossfade again. The blend workload gate was 900 against a corpus whose lightest frame scores 1,323, so every switch was a hard cut; it is now 6,000 (corpus median 1,615, p90 3,445, max 10,523), device pressure is judged at twice the frame budget, and the two calls a switch makes produce one decision. A sim-clock controller owns blends: a hidden tab freezes a blend mid-dissolve instead of skipping it, a gated frame suspends progress, the reveal holds while the incoming preset's shaders warm (capped at 2 s), each cut records why, and the dissolve starts from the outgoing frame rather than black (`4947627f5`, #1142, `16398e55c`, #1323).
- The first-run preset runs at 60 fps on a handheld. On an RG Vita Pro (RK3576, Mali-G52, Chromium 133) a switch no longer starts a second frame loop on top of the audio loop, the WebGPU composite shader shrank from 333 KB to 29 KB (407 texture samples to 23), and dropping the dock meter's per-frame CSS transitions alone took it from 20.8 to 60.1 fps. The first-run preset went from 10–20 fps to 60 fps locked, the switch stall from ~3.7 s to 0.3–0.7 s (≈1.7 s for presets with their own shaders), and 12 rapid switches stay at ~450–650 MB where the GPU process used to grow to 1.4 GB and be killed (#1209).
- Fields a preset omits take projectM's `PresetState.hpp` defaults instead of 0. The Butterchurn import omits ~84 fields per preset, so white waves drew black and ~520 presets paid for invisible motion-vector grids. Across 949 presets matched to their upstream originals, the share whose renderer state differed went from 94.8% to 16.8% (median differing fields 5 → 0) (`dab208a68`).
- Live audio reaches presets wherever AudioWorklet runs. The worklet mapped magnitudes to bytes linearly while consumers expected AnalyserNode's dB scale, so music produced bytes of 0–12 and the bands flatlined; on the demo stream the bass band went from 0.006 to 0.676. A boost-only waveform AGC stops quiet sources drawing a flat bar (`geiss-casino` energy–motion correlation 0.00 → 0.56) (`a86aa5698`, `4ceb28ba7`).
- Equations the parser had dropped now run. `loop()` and `while()` execute, bounded at 2,097,152 iterations per block (~55 presets had never run them); a failed statement with no `=` is reported instead of discarded (compiling 2,686 files reported 0 errors while losing 64 statements); and split loop heads, `loop (` and compound assignment parse, taking control statements across the corpus from 307 to 330 in 56 → 63 presets (`0309c8af8`, #1122).
- Waves and shapes draw where MilkDrop puts them. y points up: a shape at y 0.8 drew 80% of the way down the screen and now lands 19.9% down, where projectM puts it at 20.0%; a custom wave spanning x 0→1 lit 22–78% of the screen width and now spans all of it; portrait shapes stay round; wave modes 6 and 7 (842 presets) follow MilkDrop 2's geometry; and the five full-alpha main-wave trails are gone (on `geiss-tokamak-plus-painterly`, WebGL, the share of pure-white pixels at frames 120/300/600 went 15.9/1.3/17.1% → 0.6/0.0/0.8%) (#1317, #1319, #1320).
- Per-pixel `rad`/`ang` follow MilkDrop on both backends. On WebGPU they were unbound, so the statements reading them were dropped: 922 of 1,620 per-pixel presets read them, and 594 of those drew nothing from their per-pixel code. WebGL's CPU mesh measured them from `cx`/`cy`, applied the aspect twice and flipped `ang` (945 presets read them). On WebGPU, per-pixel writes to `cx`/`cy`/`sx`/`sy`/`zoomexp` reach the warp (`5f42a2eac`, `8d9aa584c`, #1305).
- Custom waves keep their per-point colour and alpha; the WebGPU path painted each wave in its flat `wavecode_N_r/g/b` (897 of 2,690 presets write per-point colour). Waves that carry `t1`–`t8` from point to point stay on the CPU, and the 205 presets that mix GPU and CPU waves draw both groups, once each (`a1e5d3c8f`, `5b1645474`, #1174).
- Post effects follow MilkDrop: brighten, darken and solarize use MilkDrop's curves (the old solarize mapped black to white), gamma stays in the measured projectM form, video echo is drawn at display instead of being re-flipped into the accumulator (`rovastar-parallel-universe` self-correlation under its own 180° rotation 0.665 → 0.973; projectM 0.9994), and shapes reload their authored values each frame (`eos-phat-cubetrace-v2` 61.66% → 45.13% mismatch on WebGL) (`292434975`, `edfd40141`, `282b23c2d`, `25792aa28`).
- Fast preset switching no longer ratchets quality to the minimum. At a steady 8 ms frame, four swipes used to reach `minimal` (density 0.55, render scale 0.75); eight rapid switches now hold one step down and recover (#1126).
- Mesh transform cache removed: it quantized coordinates to 1/2048, so distinct points collided onto one key and a motion-vector point could receive a mesh vertex's transform from the opposite edge of the screen — four vectors per frame drawn ~2.0 NDC out of place. It served ~86 of ~24,320 transform calls per frame while every per-pixel preset paid a key computation, pool bump and `Map.set` on all ~1764 vertices (`fix(milkdrop)`).
- **Correction to `ef60340cd`:** that commit's message claims its per-vertex hoist was bit-exact. Later verification with fixtures that genuinely emit motion vectors found one branch — no per-pixel program, legacy `mv_dx`/`mv_dy`, direct path (≤288 cells) — where four vectors per frame differed. The change was in fact a partial _fix_ for the cache-collision bug above rather than a no-op, and no shipped preset reached that branch (all 1,782 swept). The cache removal supersedes it.
- oEmbed titles name a preset's author once. `functions/api/oembed.ts` used the raw catalog title, which already leads with the author, next to its own byline ("Krash & Rovastar - Cerebral Demons (Stars Remix) by Krash & Rovastar"); it now formats titles with `presentTitle()`, as the link-preview middleware does (#1324).
- The keyboard is no longer treated as a TV remote on every page. `gamepad-navigation.ts` handled arrow keys, Enter and Backspace/Escape as D-pad input wherever it was installed — so on a desktop each → that changed the preset also walked DOM focus one element along (from the stage, which is not in the focusable list, that meant the skip link, left sitting visible over the visuals), and each Backspace re-dispatched a synthetic Escape that closed whatever panel was open. Keyboard-as-remote is now an explicit option, on only for smart-TV / leanback devices; real gamepads are polled as before (#1208).
- Closing a panel no longer reverts a preset change committed in the same tick. `updatePanel`, `handlePresetSelection`, `handleBrowseRecovery` and `handleAudioStop` spread the `routeState` their render captured, rewriting every other field to that render's values; `commitRoute` takes an updater and each changes only what it owns. With the point above this was Backspace in Browse: the previous preset, the panel closing, and the preset put straight back as a 2.5 s blend of itself (#1208).
- The quick-select digits are worn only while they answer. Opening Browse focuses the search field, where a digit is a search; the badges showed regardless, so pressing 1 wrote "1" into the query and filtered the list. They hide while the field has focus and return the moment focus leaves it; the shortcut listing says so (#1208).
- The transition is reported one way. The product default (2.5 s) is now a rung of the dock ladder (Cut / 1 s / 2.5 s / 5 s) and of the Settings list; before, a fresh visitor read "2.5s" on the pill, nothing marked in the popover (exact match), and "2s" marked in the overflow menu (nearest match). Both ladders mark by the same exact rule, and an off-ladder Settings value is printed on the menu's group label, which on a phone is the only place the transition is shown. The palette action is `transition-2.5s` (was `transition-2s`) (#1208).
- One status channel no longer silences the other. The toast hook picked `statusMessage ?? runtime.status`, and the shell's status line is sticky, so a single Space press ("Paused…", "Resumed.") shadowed every runtime line for the rest of the session — no "Loaded <preset>", no blend refusal, no shader-approximation notice. Each channel now shows when it changes; a repeated runtime status still shows once, and pausing twice says "Paused" twice (#1208).
- The status toast no longer paints over the Cue deck card. The card announces that it holds the bottom of the screen, the same signal the sheets and the stage menu already send, so toasts dodge upward while it is up. The two returning-visitor cards — Perform and the Cue deck — also take turns instead of appearing together in opposite corners: the cue deck waits until the Perform card has been dismissed or something has been pinned (`frontend/stage-hint-cards.ts`) (#1208).
- A paused stage keeps its transport up. The dock faded three seconds after Space, leaving a frozen frame under a "Controls" handle — the same picture as a hung renderer — and the audio-status button still claimed "the visuals are reacting". The bar now holds while paused, and the button says the picture is held (#1208).
- A second catalog copy of the same work is not listed as a relative: `goody-need` and `cotc-goody-need` share a title, and the lineage section read "1 more in the Need family" over two identical "the original" rows. Same-work duplicates collapse, keeping the copy on stage (#1208).
- The Browse hint reads "Click a card to play it" on non-touch devices; "Tap" was the only variant (#1208).
- A corpus-wide black/white/flat-frame sweep of the 1,787 bundled presets (WebGPU verified at 10–13 s, WebGL on the flagged set) found 342 broken; the causes with a compile-level signature are fixed here. Custom-wave per-point code that writes `dx`/`dy`/`zoom` no longer lowers to an undeclared `fieldTranslateX` in the wave's WGSL (31 presets rendered nothing and logged `unresolved value`); a negative `zoom` keeps its sign through every path — CPU mesh, WGSL transform, TSL blend and the WebGL warp — so MilkDrop's `zoom = -1` point mirror works instead of collapsing onto the centre pixel (23 presets); the runtime-selected warp/overlay texture path no longer binds all eight noise volumes into every pipeline, and the device asks for the sampled-texture limit the adapter offers, so presets that bind 17 textures composite again (14 presets); HLSL `normalize(float)` lowers to `sign()` instead of an invalid WGSL vector normalize (4 presets); the WebGPU per-pixel compiler knows NS-EEL `equal()`, `sqr()`, `bnot()`, `band()` and `bor()` instead of dropping every statement using them (306 presets carried such code) and the drop warning now names the call; and converted Butterchurn bodies whose `int` loop counters lost their declarations are hoisted as `int`, not `float`, so they compile on WebGL (4 presets) (#1206).
- Live editor field writes no longer silently no-op (`0ac8beb19`).
- Preset transitions no longer invalidate the frame's WebGPU command buffer (`b240f09f9`).
- Removed duplicate `.milk` files and normalized Geiss/Aderrasi catalog metadata (`1c40734ce`); search index re-embeds presets whose description changed (`430ae2e4f`).
- Stopped three tests failing on the CI runner and nowhere else (`61fabeadf`).

### v1.3.0 — 2026-07-29

#### Added

- Preset equations restored across the bundled Butterchurn corpus. `scripts/butterchurn-eel-transpiler.ts` converts upstream JavaScript equation strings back to MilkDrop EEL, writing canonical `.milk` output (`per_frame_N`, `per_pixel_N`, `wave_N_per_pointM`, `shape_N_per_frameM`, `wavecode_N_*`, `shapecode_N_*`). 157,950 statements recovered; 1,629 presets gained per-frame code and 1,068 gained per-pixel code, and 1,739 presets compile clean (`af6d373c9`, `c065a6823`).
- Custom shape instancing (`shapecode_N_num_inst`): per-frame shape code runs once per instance with `instance` and `num_inst` in scope. 304 bundled presets declare multi-instance shapes and 232 vary geometry by `instance` (`5e7bc5fb8`).
- `randint`, `log10` and `gmegabuf` in the preset expression language (`c065a6823`).

#### Changed

- Settings, the browse drawer and the dock are redesigned as a console-style workspace (#1081).
- VM program blocks compile to one JavaScript function each instead of one per statement, taking VM CPU cost from 2.00 ms to 1.10 ms a frame (`c065a6823`).
- The WebGPU compute VM reuses its readback buffers and packs its signal arrays (`97e9b640e`).
- Fullscreen rendering quality, refresh targets and effects are tuned for flagship phones (`30f5b8d54`).
- URL overrides are handled in one place, with a sanitized storage accessor and an override reset helper (`12dae71a9`).
- The custom shape radius floor is 0.002 instead of 0.04, so MilkDrop's instanced dot fields keep their authored size (`5e7bc5fb8`).

#### Fixed

- Microphone permission errors say what to do, and a missing or busy device falls back with its own message (`f75490c59`).
- Microphone capture works on mobile browsers (`67bdc66c6`).

### v1.2.0 — 2026-07-24

#### Added

- A header bar over the stage with the preset title, author and an eight-bar audio level meter (`c87525cdd`).

#### Changed

- Compiled presets (up to 50) and JIT-compiled expressions (up to 2,000) are cached, and Browse search is debounced (`c87525cdd`).
- Hashed assets and API calls bypass the service worker, which no longer caches HTML for non-navigation requests (cache v5 → v6); static paths skip the Pages middleware, and a 404 page exists (`3ea0ec87b`).
- 156 test files moved into `tests/{unit,compat,corpus,e2e}`. Test profiles come from the folder, and the runner rejects a test file outside them (`9a8188128`).
- Unit tests import a `test-utils` leaf module instead of the runtime barrel, the microphone e2e is skipped on CI, and e2e timeouts are longer (`9aa70b90d`, `fac80fd0f`).
- The app source moved from `assets/` to `src/` (`8606db2f4`, `30f6b1c82`).

#### Fixed

- The workspace requested the microphone with a bare `{ audio: true }`, so the music-oriented constraints (no echo cancellation, noise suppression or auto gain control) never applied on the main path, and capture misbehaved on mobile; they apply now (#1078). The engine-mount e2e checks the constraints (#1079).
- Preset search ignores punctuation, matches each word of a multi-word query and finds presets by collection name, and removing the collection from the URL clears the filter (`536111b45`).
- Preset mood labels are corrected, and the legacy stage overlay controls are styled (#1077).
- The `play-toy` capture script waits up to 15 s (was 5 s) for `?audio=demo` to start audio, so agent-integration runs stop failing intermittently, and CI runs the e2e profile under xvfb (`a82ade9eb`).

### v1.1.0 — 2026-07-19

#### Added

- A buildable project (June 2025): a `package.json` with Jest tests (#19), a Vite build that bundles three.js from npm instead of cdnjs (#63), TypeScript (#67, #69), and one `toy.html?toy=<id>` loader in place of per-toy pages (#68).
- Bun and Cloudflare (December 2025): tests on Bun's runner with `bun.lock` and CI on Bun (#195, #223), a Cloudflare Pages build (`d3e1fff90`), and an MCP server for agents (`6e5af978b`, #243).
- The MilkDrop engine (March 16, 2026): a `.milk` parser, an EEL expression parser and evaluator, a compiler, a per-frame VM, a three.js renderer adapter, a formatter, and a CodeMirror editor that compiles in a Web Worker, with a catalog of 4 presets (`107dc17ce`). The same day, 26 toys were rewritten as `.milk` presets (`012b4693c`).
- WebGL and WebGPU renderer adapters and feedback managers (March 19, 2026; `cf4881431`).
- Parity measurement against projectM (March 28–31, 2026): a parity artifact manifest, imported projectM reference renders for diffing, catalog fidelity labels synced from measured parity, and the 37 projectM upstream test presets under `public/milkdrop-presets/libraries/projectm-upstream/` (`bb54ed222`, `63f45d4f2`, `29a03ee39`).
- A React workspace with an engine adapter and URL state (April 3, 2026; `f2ad4189a`), and REPL-style editing (`3a10cf07c`).
- Equations on the GPU (April–May 2026): a GPU VM runner and WGSL generator (`f0c73fa85`), WebGPU render bundles (`bf3b45a0e`), and an expression-to-TSL compiler that runs per-pixel equations in the WebGPU fragment shader.
- MCP tools for listing and searching presets and an agent session manager (May 17, 2026; `83bbc577a`, `77125ea82`).
- The EEL JIT (May 29, 2026): expression trees compile to JavaScript functions in `expression-jit.ts` (`c09e9fe2e`).
- 1,748 Butterchurn presets imported, taking the catalog from 43 to 1,791 entries (May 30, 2026; `5def9c431`), and Cloudflare Functions for AI preset generation, D1 and R2 (`60a1d42c6`).
- MilkDrop 2 shader passes on WebGL (June 14, 2026): warp and composite are separate passes, with `sampler_fc_main` reading the warp output, and `sampler_blur1`–`sampler_blur3` read blur targets at 1×, 0.5× and 0.25× the feedback resolution.
- Performance controls with a persistent pixel ratio, a particle budget and shader-quality presets (`fc2a29f23`), and `useEngineSnapshot()` for frame-accurate engine state without re-rendering the whole UI tree (`4baa29dae`).
- Native projectM 3.1.12 reference renders: a C++/SDL harness linked against libprojectM renders the frames parity captures are compared with (`70200ac1c`), and `250-wavecode` is certified on WebGPU (mismatch ratio 0.0023); of the four shipped presets, one is WebGL-only and three remain uncertified (`1e292efa9`).

#### Changed

- Biome replaced ESLint and Prettier (#395), three.js is imported by name, including its WebGPU entry points (`21c8b42a8`), and toys share one `createToyRuntime` (#443) (January 2026). Every toy was flagged WebGPU-first with a WebGL fallback (#757).
- The 26 alias-pack presets were replaced with curated ones (#884), and "toy" routes and copy gave way to `/milkdrop/` as the canonical route (#948).
- The workspace view split into `AudioSourcePanel`, `BrowseSheetPanel` and `SettingsSheetPanel` (`715fb1b80`).
- Theme and launch-screen rules moved out of `app-shell.css` into `shell-theme.css` and `shell-launch.css` (`93c1c15e2`).
- The light theme's muted text, line and accent colours are darker, dialogs, toasts and active controls are styled in light mode, and `prefers-contrast: more` and forced-colors mode have their own rules (`e988bdecd`).
- In live mode on narrow screens the stage fills the full dynamic viewport height with no padding or rounded corners, and the page stops scrolling.
- The VM reuses custom-wave objects across frames and writes mesh positions into one reused `Float32Array`.

#### Removed

- The last loose HTML toys (`c7d2dc523`), 38 toy modules under `assets/js/toys/` (`cf4881431`), `toy.html` (#948), and the remaining bootstrap, loader, library-view and toy-view code, about 11,500 lines (`a96b7b092`).

#### Fixed

- The 2D and volume noise textures use projectM's memory layout, in which Y (2D) and Z (volume) vary fastest (`f7b2896f5`).

### v1.0.0 — 2024-11-02

#### Added

- A static site of 16 standalone HTML pages, most reacting to microphone input, and an index linking 10 of them: 3D Toy, Star Guitar Visualizer, Defrag Visualizer, Evolutionary Weirdcore, Multi-Capability Visualizer, Trippy Synesthetic Visualizer, Pattern Recognition Visualizer, SVG + Three.js Visualizer, Dreamy Spectrograph and Interactive Word Cloud.
- Pages load three.js from cdnjs (r128 to r152, and the 0.169.0 `three.webgpu` build) or draw with raw WebGL, WebGPU or Canvas 2D. The Multi-Capability Visualizer tries WebGPU and falls back to WebGL.
