Conversation
Feather could not ship one thing. Two workflows fired on `tags: - "*"`, so a one-character CLI fix cost a notarized desktop build on four platforms, a new LuaRocks rock, and a republished VSIX. `.husky/pre-commit` re-applied the lock-step on every commit, not just at release. Tags are now per-train: v* platform: runtime + cli + desktop together cli-v* cli only runtime-v* runtime only desktop-v* desktop only ext-v* extension only release.yml is replaced by three reusable workflows plus thin triggers, so `v*` still fans out to a full platform release while each prefix fires only its own job. scripts/release-tag-version.sh is the single definition of tag parsing and rejects anything outside the vocabulary. set-version.sh now takes a train and writes only the files that train owns. It was also macOS-only (BSD `sed -i ''`) and is portable now. pre-commit enforces just the desktop trio, which is one artifact described in three files. The LuaRocks publish is skipped when src-lua/feather is unchanged since the previous runtime release, so a platform tag never burns a rock version for an unmodified runtime. Session health compared the runtime version against the desktop version and warned when they differed. That only held because lock-step made them equal by construction; decoupled trains are expected to drift, so the check is removed. Both versions are still displayed and config.API still carries compatibility. The e2e fixture now pins a deliberately different runtime version so the tolerance is covered by a test. Phase 01 of V2.md.
Phase 01 decoupled the release trains, which made it possible for a user to run a runtime and a desktop app built from different commits. Nothing checked that they could still talk to each other. The runtime now advertises FEATHER_PROTOCOL_VERSION as `protocolVersion` in the feather:hello config payload, and the desktop compares it against a supported range. A game speaking a protocol the desktop cannot handle gets a named error that says what to do about it, in both a persistent toast and Session health. This rides the existing feather:hello handshake rather than adding one to ws_server.rs, so Rust stays a transparent relay. It is kept separate from FEATHER_API: that gates which plugins load, this gates whether the two sides can talk at all, and they can now legitimately differ. A runtime that reports no protocolVersion is treated as legacy, not as broken. Every runtime shipped before this omits the field, and any health warning flips the verdict to "Needs attention", so warning on absence would have degraded every existing user's session on their next desktop update. Absence shows in the Protocol chip and detail rows instead. The two declarations live in different languages and cannot import each other, so `npm run check:protocol` fails the build when they drift or when the supported range is incoherent. Wired into pre-commit and CI. Phase 02a of V2.md.
The 78 wire messages existed only as string literals in three languages. Nothing could tell you that the desktop handled a message the runtime never sent, or that a command was dropped from the runtime while the UI still sent it. packages/protocol (@feather/protocol) now declares the set: 44 messages desktop -> game and 34 game -> desktop, derived from the two authoritative dispatch tables rather than from scattered call sites. check:protocol verifies the registry against both tables in each direction and fails with per-problem remediation, so adding a handler without declaring it — or declaring one nobody implements — breaks the build. This covers message names, not payload shapes. Names alone are what make drift detectable, which is the property that matters now that the runtime and desktop ship on separate trains. Payload schemas for 78 messages are a long tail where a subtly wrong shape is worse than an implicit one, so they are tracked separately in V2.md. packages/ temporarily holds both the Love2D catalog and this package. generate-registry.mjs filters on .json so the subdirectory is invisible to it; the catalog moves to catalog/packages/ in Phase 06. Phase 02b of V2.md, partial.
The desktop parsed inbound messages with an unchecked `JSON.parse(...) as WsMessage` cast, and the runtime dropped unrecognized commands off the end of its if/elseif chain. Neither noticed version skew, which is now reachable. parseInbound validates the envelope and reports an unrecognized type once per type as a skew signal rather than swallowing it. The desktop->game set is generated into src-lua/feather/protocol_messages.lua so the runtime can say when a command came from a newer desktop; check:protocol fails if that file goes stale. Payload shapes are deliberately not modelled. The messages are not uniform: eval:response carries id/status/result/prints at the top level while others nest under data. A first cut of parseInbound rebuilt the envelope from four known keys and silently dropped those fields, which golden.spec.ts caught. That is what a slightly wrong schema costs, so the envelope is exact and data stays unknown. No Rust structs are generated: ws_server.rs deserializes only AuthResponseMsg and relays the other 77 messages as raw text. Phase 02b of V2.md.
No directories move in this change. The point is to get the task graph and strict dependency linking in place on a layout that is known to work, so that later phases fail for their own reasons rather than for tooling ones. pnpm-workspace.yaml lists members explicitly rather than by glob, because packages/ also holds Love2D catalog *.json data that is not a package. turbo.json defines root tasks in //# form plus package-level build, typecheck and test; a warm run is FULL TURBO in 11ms. Two things worth recording. pnpm 11 no longer reads the pnpm field in package.json at all — build-script approval is now allowBuilds in pnpm-workspace.yaml, and a blocked script is a fatal install error that pnpm re-triggers before every script. And the Playwright failure was not the predicted undeclared-dependency problem: npm's node_modules/playwright survived as a real directory while @playwright/test resolved its own copy under .pnpm, giving two module registries and zero discovered tests. A clean reinstall fixed it; no source import needed changing. Contributor commands in AGENTS.md and CONTRIBUTING.md move to pnpm. The end-user npm install -g line in README.md stays npm. Phase 03 of V2.md.
The creative tools ship both inside the desktop app and as a web page, but they reached for @tauri-apps directly. That is why the desktop pages had started importing browser implementations out of src/showcase: the capability had no name, so the seam got crossed instead. @feather/host names it. The interface is the five operations the studio actually performs, with a Tauri and a browser implementation; createTauriHost sits behind its own entry point so a web build never pulls Tauri into its bundle. Three separate hand-rolled isWeb() file pickers collapse into it. A lint rule now rejects @tauri-apps in the studio directories. @feather/session-bridge abstracts one function. The four modules the plan expected to need injection turned out to be portable except send-command, which is the only one that touches invoke. It is a registry for a single CommandSender defaulting to a no-op, which is what makes a missing session a normal state rather than a missing import. @feather/ui takes the 33 primitives. Four of them needed app state — the resolved theme and the clipboard-with-toast helper — so those are injected through UiProvider rather than imported, which is what keeps the package usable by a second app. use-local-particle-playground moved to the studio rather than into the host: it is workspace state that happens to persist locally, not a host capability. src/showcase is now a six-file shell. The studio no longer imports Tauri directly. Five transitive paths still reach it through inspector hooks, which cannot move until the apps split; recorded against Phase 05. Phase 04 of V2.md.
src-lua becomes packages/runtime-lua with a package manifest and its own bundler. cli declares it as a workspace dependency and calls packages/runtime-lua/scripts/bundle.sh instead of reaching up into ../scripts/ to copy a sibling's source into its publish directory. The Love2D catalog moves to catalog/packages/, so packages/ means workspace packages and nothing else. The publish target is unchanged: registry.yml still pushes to the packages branch, only its trigger path moved. Registry output verified byte-identical at 34 packages and 51 entries. The hand-maintained paths: filters are gone from the five build and test workflows, replaced by turbo's input hashing plus an actions/cache step for .turbo — without a persisted cache the filtering would be theatre. pages.yml and registry.yml keep theirs; those are deployment triggers, not build selection. pre-push runs the four suites through turbo, so an unchanged suite is a cache hit rather than a rerun. Moving a directory one level deeper broke two path assumptions worth noting. A CLI test derived the repo root as dirname(LOCAL_SRC), which now points at packages/. And the runtime release zipped to ../, which would have written the archive into packages/ while the release step looked for it at the root — that one would only have surfaced during a real release. CHANGELOG.md keeps its old src-lua paths: those entries describe releases that shipped, and rewriting them would falsify the record. Phase 06 of V2.md, landed ahead of 05 since none of it depends on the app split.
The global src-lua path rewrite also hit V2.md, turning the section 1 diagnosis and the Phase 06 task list into circular statements. The historical text describes the v1 state and is restored; the task list is marked done with what actually landed. Records D23 (a directory moved one level deeper broke two encoded path depths) and D24 (Phase 06 ran ahead of 05, since none of it depends on the app split).
Two shortcuts around splitting store/settings.ts were evaluated and both are closed. Leaving it in place fails the boundary. Moving it wholesale into a shared package is circular: settings imports the texture lab generator and the shader graph constants, so app-state would depend on the studio while the studio depends on app-state. So the studio preferences have to come out of the settings store first, and that store is persisted. Its partialize and merge both special-case texture lab workspace snapshots, so a naive split silently discards every existing user's saved recipes. Documents which fields are studio-owned, what the migration has to do, and the order to pick the phase up in.
The product is at 4.0.0, so calling the re-architecture "v2" read as a different major line rather than the shape of the current one. V2.md becomes V4.md and the prose follows. Targeted phrase replacements rather than a blanket v2 -> v4, which would have hit catalog package versions (anim8 v2.2.0 and friends), the lockfileVersion, and the wire protocol version. Catalog files are untouched and check:protocol still reports v1. CHANGELOG.md keeps its wording: those entries shipped.
Independent release trains created a question lock-step never had: which versions work together? A v* tag now publishes feather-platform.json and a release body listing every component with its install command, so landing on a v4.x release gives a working set instead of a matrix to solve. It duplicates nothing. npm, LuaRocks and the Marketplace already host the CLI, runtime and extension, so the manifest links to them; the only attachments are artifacts with no other home. This replaces the per-satellite compatibility ranges section 6 originally proposed, which were worse: a declared range nobody exercises is quietly wrong. Two storage fixes came out of the same look. The extension release attached the four Bun-compiled CLI binaries — 342 MB — that prepare.mjs had already placed inside the VSIX, so every release stored them twice; standalone binaries are now published once, on the platform release. And the Turbo cache key added in Phase 06 was keyed on github.sha, minting a new entry against the 10 GB Actions cache quota on every push. It keys on the lockfile now. feather doctor gained a Compatibility group reporting the embedded runtime against the CLI version, pointing at the platform releases when they differ. Offline, no network.
Correcting the previous commit. It dropped the four Bun-compiled binaries from the extension release on the reasoning that they were already inside the VSIX, so nothing was lost. That was wrong. The VSIX serves extension users; the standalone binaries are the hermetic install path — no Node toolchain, one download — which is what a lean CI runner wants. They now attach to the CLI train instead. That placement matters: release-cli.yml fires on cli-v* as well as v*, so a CLI-only patch still produces a pinnable binary. Publishing them only on platform tags would have forced pipeline users to wait for a full platform release to pin a CLI fix, which defeats the decoupling for the audience that most needs it. The 342 MB of per-extension-release duplication is still gone, and the platform manifest links to the binaries rather than copying them. docs/ci.md covers what a pipeline actually needs: the two install paths and npm's Node >=22 engine, the LÖVE and xvfb requirement, the doctor --production --json release gate, and which commands do not belong in CI — run and watch both expect a live game and a desktop app on the other end of the WebSocket. Two things in that page came from checking rather than assuming. build takes a platform subcommand, not a --target flag; --target is a doctor flag. And only upload, remove, package remove and plugin remove actually refuse without --yes non-interactively, so the page lists those four rather than the wider set I first wrote.
…store Phase 05a. This is the blocker the rest of Phase 05 waits on: the Inspector settings store imports the texture lab generator and the shader graph constants, so any shared app-state package would depend on Studio while Studio depends on it. settings.ts no longer imports Studio code at all, and drops from 374 to 197 lines. The extracted state is persisted user data — texture recipes and saved workspaces people built by hand — so the migration reads the legacy settings-storage key once and never writes or clears it. Rollback stays possible. The payload is versioned because a separately installed Studio has its own data directory and storage origin: it can never read that key, so StudioPreferencesV1 has to travel over the bridge in 05b. importPreferences is idempotent and refuses a payload from a newer Studio rather than dropping fields it cannot read. The e2e found a real gap. Adopting inside zustand's merge does not write, so a migrated payload sat in memory until the user happened to change a Studio control, and was redone every launch until then. The store now materializes on rehydrate, which also records the distinction the two-step migration needs: migratedFrom 0 means this install started clean, not that it never ran. Five Studio files now import no Inspector settings at all. The MCP creative bridge follows the state for now; its creative half moves out in 05c.
Phase 05b. apps/studio is now its own workspace package, Vite build and Tauri application: separate product name, bundle identifier, version, port and output directory. Its crate deliberately omits axum and tokio, so it runs no WebSocket server, and tauri-plugin-shell, so it spawns no CLI. Both omissions are the boundary: adding either would make Studio a second thing that can reach a running game. Its Vite config has no alias back into src/, so an import of Inspector code fails to resolve rather than quietly recreating the coupling. It builds to a 197 KB bundle with the Tauri host in a separate 5 KB lazy chunk, which is what makes the web build genuinely Tauri-free. @feather/session-bridge grows from an in-process sender registry into a versioned contract. negotiate() is a range overlap rather than an equality check — equality would force the two applications back into lock-step, which is the coupling v4 exists to remove. Thirteen unit tests cover the cases that matter for a surface that can execute inside someone's game: commands refused before a handshake, a forged capability, one revoked mid-session, one already expired, an incompatible peer, and no attached session. The null bridge is tested as a supported state, not an error path. The Inspector half binds 127.0.0.1, not the wildcard the game socket uses, because this endpoint accepts commands on behalf of another local application. Capabilities are in-memory with a 30 minute TTL and travel in the request body rather than a URL, so they stay out of logs. Port 4006; 4005 was already the MCP bridge. The preferences handoff is contracted and client-side tested but its Inspector handler still returns null: Studio's preferences live in the Inspector webview's storage, which the Rust process cannot read. That waits for 05c, when the state has a reachable owner.
Phase 05c. 25,502 lines across 73 files leave the Inspector: the three tool directories with their types, stores, hooks, constants and preview components. The showcase goes with them, because it is Studio running in a browser and leaving it behind would have preserved the very coupling this removes. The boundary is now real and checked. Zero Inspector to Studio imports, zero Studio to Inspector imports, and no @tauri-apps reachable from Studio's 91-module import graph. The five transitive Tauri paths recorded in Phase 04, all of which ran through use-ws-connection, are cut. Studio does not import Inspector's session state; it mirrors what the bridge reports. The mirror keeps the Inspector store's shape on purpose, so the tools crossed the boundary without being rewritten, but they are reading Studio's own state and there is no import path back. With no Inspector attached the mirror is simply empty, which the tools already understood as "no session". Bidirectional lint guards enforce this, and lint now covers apps and packages rather than src alone. The guard immediately earned itself: it caught the creative MCP bridge importing Tauri directly. That is a transport, not logic, so it moved behind an interface with the Tauri implementation in apps/studio/src/desktop — Studio's one Tauri-only area, mirroring packages/host/src/tauri. Eleven app e2e tests were removed rather than ported: they exercised the tools through the Inspector shell, a configuration that no longer exists, and the showcase suite already covers the same ground. The four studio preferences migration tests moved to the showcase suite, where Studio actually lives. App e2e 44, showcase e2e 21.
Phase 05d, in part. set-version.sh gains a studio train that writes only Studio's manifest, crate and Tauri config; platform deliberately does not touch it, because Studio is a satellite rather than a platform member. release-studio.yml fires on studio-v*, builds the signed four-platform matrix from apps/studio, and publishes to its own release feed so a Studio update never surfaces as an Inspector one. It asserts its own isolation too: the job fails if any other release workflow ever starts matching studio-v*. The showcase is now a thin shell — an index.html and a three-line entry that imports Studio's public web mount. Root vite.showcase.config.ts and showcase.html are deleted. The love.js middleware, CSP block and Lua watch list that were copied between two Vite configs now live once in scripts/vite-lovejs-plugin.mjs, and the Inspector does not need them at all since the previews left with the tools. Its Vite config drops from 198 to 73 lines. That closes the duplication recorded as evidence in section 1. One failure was worth the time it took. Giving the showcase its own Vite root changed what Tailwind scans, so it stopped generating classes for packages/ui. Radix select and dialog rendered but had no positioning, and Playwright reported elements as visible, stable, and outside the viewport. Nothing errored — the CSS bundle was just 75 KB instead of 132 KB. Both Studio stylesheets now name packages/ui explicitly, and so does the Inspector's, even though it currently works by accident of its Vite root being the repository root. That accident ends when src/ moves.
src/ and src-tauri/ move under apps/inspector, so the target layout in section 5 now exists: three applications, five packages, one catalog. Nothing about what ships changes — Inspector keeps its bundle identity, signing and release feed. Configs, the Rust manifest path, tauri-action's projectPath, set-version.sh's desktop trio, the pre-commit trio check, turbo inputs, eslint globs and the protocol check all follow. Two things this turned up. scripts/tests/ held fourteen unit test files with no runner: not in package.json, turbo, the husky hooks or CI. They cover shader graph codegen, particle timelines, texture generation and theme resolution, and they had been broken since before this work — verified at baseline 41689d1, where the theme registry already used extensionless imports Node's ESM loader cannot resolve. Run under tsx, which honours the tsconfig paths, all of them pass: 93 tests. They are now pnpm run test:unit, wired into turbo, pre-push and CI, and they happen to cover exactly the logic Phase 05c moved into Studio. The Rust build failed after the move with a missing permissions file. The cause was the target/ directory travelling with the crate while holding absolute paths to its old location. Removing it fixes the build; it is gitignored output, not state worth keeping.
Three Playwright configs, each beside its product, replacing the two that assumed one application. Studio gets a suite of its own covering what is unique to it rather than to the tools: that it boots standalone, treats the Inspector bridge as optional, reports a failed connection without breaking, and knows which host it is running under. The tools themselves stay covered by the Showcase suite, which renders the same components. check-artifact-boundary.mjs scans the built bundles for markers of the other application. The lint guards catch source imports; they cannot see a transitive or dynamic import that pulls one app into the other without any single file importing across, and that is what actually ships. It runs in CI after both builds. Two things surfaced while wiring this up. pnpm passes `--` through literally, so `--host` in a webServer command never reached Vite and every suite timed out; the servers now bind explicitly. And Playwright runs webServer.command from its config's directory, so the Inspector and Showcase configs need an explicit cwd now that they no longer sit at the repository root. Extending lint to apps/ also exposed fifteen dead helpers in the Inspector e2e file, left behind when the Studio tests moved out.
Section 5 originally placed the CLI and the extension in packages/. That was wrong by the plan's own rule: packages/ is for things consumed by other things, and both of those are artifacts a user installs. apps/ now holds everything that ships — inspector, studio, showcase, cli, vscode-extension, docs — and packages/ holds only what the applications consume. Paths follow: the pnpm workspace, turbo inputs, root scripts, the husky hooks, five CI workflows, the registry, plugin-catalog, manifest and Tauri sidecar generators, set-version.sh, .gitignore, and zensical's docs_dir, which defaults to ./docs and had to be told. All 31 docs symlinks are relinked and verified to resolve; three point at siblings inside apps/ and so needed one level fewer, not one more. Depth arithmetic was the recurring hazard rather than the moves. The CLI resolves the repository runtime from its own dist directory, and two test files derived the repo root independently of the shared helper; all three were off by one afterwards and are now verified by resolution rather than inspection. Extending lint to apps/ pulled the CLI and the extension into scope for the first time — the old script was `eslint src` only — surfacing about twenty-five pre-existing issues: unused bindings, a useless escape, a missing cause on a rethrow, and react-hooks/exhaustive-deps not registered for the CLI's Ink components. All real, none related to this work, so they are carved out with a comment and recorded in section 8.1 rather than smuggled into an architecture commit.
Section 7 planned an Inspector MCP and a Studio MCP with --target to choose. One server is the better answer. MCP's consumer is an agent, and making it configure two servers with two tokens is worse for exactly the audience the feature serves. The server is also not a release boundary: its capability tracks the two applications, it has no cadence of its own, and it already ships inside the CLI binary, which is the hermetic install path. What actually changed is that creative state moved to a second process, so the single server now routes. /creative/* reaches Studio's own loopback endpoint; everything else reaches Inspector. An application that is not running is named in the error rather than surfacing as a refused connection, because "connection refused on 4007" tells an agent nothing it can act on. This uncovered a break from 05c. Studio's frontend transport was invoking set_mcp_creative_snapshot and resolve_mcp_creative_request — Tauri commands that live in Inspector's shell. Studio had no invoke_handler at all, so its creative MCP was dead code that would have thrown. creative_mcp.rs implements them, with a per-launch token and a relay to the webview. Studio therefore gains axum and tokio, which 05b's comment said would breach the boundary. That comment was too blunt: the boundary is not "no HTTP server" but "no second path to a running game". This endpoint serves Studio's own authoring state, and pushing into a game still goes through the authenticated Inspector bridge. Studio still runs no WebSocket server and owns no sessions. Assigning the Studio bridge to 4006 in 05b collided with the CLI's MCP HTTP transport, which defaults to that port; nothing caught it because neither runs during the suites. The bridge moves to 4008 and the map is written down.
The documentation source moved to apps/docs and zensical still writes its output to site/ at the repository root, so docs/_site/ no longer refers to anything.
The one-time preferences transfer now works end to end. Inspector's Rust relays preferences.export to its webview, which answers from the legacy settings-storage slice; Studio imports it on first pairing when it has never migrated. The Inspector side is a read-only adapter that renders nothing, mutates nothing and imports no Studio code — its field list comes from STUDIO_PREFERENCE_KEYS in the shared bridge contract, which is what stops the coupling coming back. The Inspector copy is never deleted, so pairing twice is harmless and going back loses nothing. check:protocol gained two guards. The bridge version and range must agree between the TypeScript contract and the Rust server, which cannot import each other. And Studio source must not reference the runtime wire protocol version at all: Studio pairs with Inspector and never speaks to a game directly, so Inspector owns that translation. Both verified firing. apps/docs/studio.md covers what a user actually needs: why the applications are separate, installing either without the other, that standalone Studio is the normal case rather than a degraded one, what pairing does, and that their existing recipes and workspaces come across without the old copy being destroyed. CHANGELOG carries four entries for the split.
The extension does not call the user's CLI. It bundles four compiled binaries and the Lua runtime, which makes it hermetic but leaves the versions it carries invisible. featherPlatform in the manifest names them. prepare.mjs now fails the package build when the snapshot it would bundle disagrees with that declaration. A VSIX claiming 4.0.0 while carrying a 4.1.0 runtime is worse than a build that stops here, because the mismatch would only surface as strange behaviour in someone's editor. The resolved versions are written to bundled-bin/platform.json and reported by the doctor command, so an unpacked VSIX also says what it holds. set-version.sh platform keeps the declaration in step, since a platform release changes what the extension would vendor. The extension's own version deliberately does not move with it: it is a satellite and releases on its own cadence.
The carve-out is gone and lint now covers apps/cli, apps/vscode-extension and .cjs files. Moving those directories under apps/ had pulled them into scope for the first time, since the old script was `eslint src` only. Most of what surfaced was configuration rather than defects. CommonJS files legitimately use require, Node test files have Node globals, and 28 eslint-disable directives had gone stale once those were declared. Genuine fixes: four unused bindings, a redundant escape inside a character class, a rethrow that dropped its cause, and a directive naming a rule that was never registered because the plugin is not a dependency. One rule was wrong. no-useless-assignment flagged the `let sessionReplayIncluded = false` in doctor as dead, but its only assignment sits inside `if (configSource)` — a project with no feather.config.lua never reaches it, and that default is exactly what the production check reports. Obeying the rule would have made that check depend on uninitialized state. Suppressed on the line with the reason. Two others were fixed by improving the code rather than silencing it. supportedUploadDoctorTargets now backs a real isSupportedUploadDoctorTarget guard instead of being a type-only const with 'itch' hardcoded next to it, and the config parse error rethrow attaches its cause.
The decision log had assigned D25-D32 twice: once to the Phase 05 owner clarifications and again to later implementation decisions. The two D32 entries directly contradicted each other on MCP topology. Every in-document citation of D25-D32 resolved to the implementation set, and nothing outside V4.md cited a decision by number, so the Phase 05 clarifications were renumbered to D35-D42. The log is now D1-D42, unique and contiguous, with a note recording the renumber. Three of the renumbered entries were also inaccurate: - D38 predicted Phase 05 would replace the command-sender registry. It did not. setCommandSender still lives in packages/session-bridge and Inspector's main.tsx is its only caller; what changed is that Studio references it zero times and reaches games through the bridge. The registry was scoped to one app, not retired. - D39 assumed state ownership implied a second MCP server. Ownership held; the topology did not. - D42 is marked superseded by D32, with the original text preserved. Section 5.4 still specified `feather mcp --target <app>` over two servers, which was never built, and section 10's Q5 still answered that both applications own distinct servers. Both now describe what shipped: one server routing /creative/* to Studio (:4007) and the rest to Inspector (:4005), with the real --studio-url/--studio-token flags. The goal table, release-ownership table, boundary rule, glossary and the 5.4 cross-reference were updated to match. User-facing docs already described the single server correctly; the stale spec was confined to V4.md.
CONTRIBUTING still described the pre-v4 tree (src/, src-tauri/,
vscode-extension/, docs/, package-lock.json) and pointed at
test:app:e2e, a script that no longer exists. README's documentation
links all pointed at docs/, which is now apps/docs/, so every one of
them was broken.
Running the project was also undiscoverable: Inspector is served by the
root vite config and has no package.json, while Studio owns its own
scripts that no root script reached. New commands, all from the root:
verify typecheck (all projects), lint, protocol and
generated-file drift — about 5s warm
test every lane the pre-push hook gates on
inspector:dev / inspector:tauri
studio:dev / studio:tauri / studio:build
Four fixes found while verifying the above:
- turbo's //#lint declared inputs of apps/inspector/src/** but the
script lints apps and packages, so an error in Studio, the CLI, the
extension or any package returned a cache hit and the lane reported
success. Verified: eslint failed a file that turbo passed.
//#typecheck:web had the same gap for the packages it pulls in
through tsconfig paths. The four package typechecks that were not
turbo tasks now are, so verify covers them and caches them.
- generate-registry.mjs stamped updatedAt with today's date on every
run, so check:registry failed on any day the catalog had not changed.
It now keeps the previous date when content is identical.
- With two src-tauri directories, a bare `tauri` from the root resolves
to Studio, so `pnpm run tauri dev` and `native` silently targeted the
wrong app. Both now pin their project directory, as CI already does
with projectPath.
- `pnpm run feather -- --help` fails with "unknown command '--help'":
pnpm mangles the first forwarded token when it is a flag. Docs now
use the form that works in both cases.
test runs its lanes serially. Three Playwright suites plus the CLI e2e
suite spawning real processes made a different lane time out on
roughly every parallel run; each passed alone. Serial takes 2m53s and
passed 7/7. The pre-push hook now calls `pnpm run test` so the local
command and the push gate are one definition. CI is unaffected: it runs
each lane in its own job.
Also refreshed skills/ (npm -> pnpm, moved e2e paths) and corrected
apps/cli/README.md, which claimed Node 18+ against an engines field of
>=22 and is symlinked as the user-facing apps/docs/cli.md.
Completes 6.4's acceptance line. F9 toggles a breakpoint and Shift+F9 edits its condition, both acting on the line you are paused at — the bindings every editor uses, so most people try them before reading anything, and acting on the paused line avoids inventing a cursor concept the source view does not have. The gutter is why this was needed. It is one button per line, so reaching line 1,847 of a real main.lua by tabbing is reachable in principle and unusable in practice — an L4 failure, not an accessibility footnote. The frames list was already fine: the frames are real buttons and a call stack is short, so Tab and Enter already worked there. The shortcut is advertised in the gutter tooltip only on the paused line, because that is the only line it can act on. Promising a key that does nothing would be worse than saying nothing. Two smaller things fell out. The condition dialog had no accessible description; Radix warns about it and the warning is right, since announcing only "Breakpoint condition — line 42" leaves a screen reader user to guess what the field wants. And the first version of the test chained toggle-on, dialog, toggle-off, and failed because focus after the dialog closed blocked the last key — splitting it tests each behaviour rather than their interaction. Verified by disabling the F9 branch and watching the test fail. V4-ENHANCED §1 records the owner's amendment: completing a promise a feature already makes is refinement; a new surface is not. Full suite 7/7, verify:enhanced 15 checks.
Comparing before against after is the entire point of a profiler, and making the change being measured means restarting the game. So the one comparison the feature exists for was the one it could not make. When a session id changes for the same device, use-ws-connection migrates cached data across: logs, performance metrics, observers, assets. Someone added assets to that list at some point. Nobody added the profiler, so the cleanup that follows dropped every snapshot — silently, with less critical data carefully preserved either side of it. Only the snapshots travel. Live capture state stays behind, and that distinction is the part worth testing: carrying recording:true into a fresh run shows a capture in progress that nothing is filling, and carrying elapsed time attributes the old run's duration to the new one. Both are the tool lying about what it is measuring, which is worse than losing the baseline. The judgement is a pure function, profilerStateForNewSession, pinned by scripts/tests/profilerCarryOver.test.ts and verified by reverting it to the old behaviour and watching two of four assertions fail. Also corrects C1's rationale, which this pass proved wrong. The store comment and the plan both claimed filters must be global because "a restart mints a new session id". The runtime derives its session id from a device id persisted to disk, so a restart usually reconnects as the same session. The decision holds for a better reason — what someone is looking for outlives the process they are looking at — and both places now say that. Still open on this surface: whether a capture's cost is obvious before starting it, and whether the panel distinguishes cheap-to-leave-open from actively-measuring. Full suite 7/7, verify:enhanced 16 checks.
Two crashes in this work came from the same root: a value typed as one thing and never checked. use-ws-connection holds 18 casts of inbound payloads, so the question was how many are live hazards. Fewer than feared, and that is the finding. Feather's own runtime is disciplined — the Lua debugger falls back to "?" rather than sending a frame with no file and filters C frames out; Compare already coerces with String(item.key); the plugin content renderer guards with Array.isArray and .every. Three candidates, two already defended. V4.md D14 declined blanket payload schemas and this audit says that holds. The undefended one is the only value authored outside this repository. A plugin's tabName comes from its own Lua manifest, and the truthiness gate admitted anything truthy, so tabName = 1234 gave the sidebar an item whose name was a number — then .localeCompare sorted it and .toLowerCase() searched it. That list is the navigation, so one third-party plugin took the whole app's sidebar down. pluginNavItems is now a pure module that coerces at that seam, and it keeps the odd plugin visible rather than hiding it: showing "1234" is a better answer than silently dropping a plugin that exists. An icon that is not a string is dropped, since it reaches a component expecting a name. The rule this suggests, recorded as C5: not "validate everything" and not "trust the runtime", but be strict where somebody else's code decides what we render. No e2e — the sidebar would not pick up an injected config through two seed helpers, so rather than keep guessing I extracted the logic and tested it directly, verified by restoring the casts and watching two assertions fail. That has been the resolution three times now. Full suite 7/7, verify:enhanced 17 checks.
Assets offers a Missing filter — which of this project's assets are not on disk. The check behind it ran over paths.slice(0, 250) and presented the result as complete. On any project with more assets than that, a file absent at position 400 was never checked, never flagged, and never appeared under the filter. The panel answered "is anything missing?" with a confident, incomplete "no", which is worse than not answering: the confident answer stops you looking. The cap existed for a real reason, so the fix is not deleting it. One stat per asset fired at once on a large project exhausts file handles. findMissingPaths checks every path in bounded batches of 64 and re-checks cancellation between batches, because on a big project this work outlives the panel that started it and someone who navigated away should not keep the filesystem busy. Tests pin all three properties, including that batching did not quietly collapse to serial, and were verified by restoring the truncation and watching two of five fail. Two surfaces reviewed and deliberately left alone, recorded so they are not re-opened: - Observability does not poll. The runtime pushes while the panel declares interest, so it is live while open, and C2 covers the dormant case. Complete as it stands. - Session Replay's six toasts are three success/failure pairs for export and import — user-initiated file operations whose outcome is invisible otherwise. That is what a toast is for, not noise. Full suite 7/7.
Compare's empty state said "Connect another session to compare runtime data", which names the requirement rather than the act. A second session means a second game, and the app has known that command since before this work started. It now shows `feather run <project dir>`, the same shape the no-session empty state uses. The larger find is a gap this work created. Two panels report "No rows match the current filters." That was self-explanatory when a filter was always something you had just typed. C1 made filters outlive the visit, so you can now reach an empty panel because of a filter set days ago on a different game — and the message names the cause while offering no way out of it. Compare and Observability now carry a Clear filters button, and TriageEmptyState gained an action slot so the next one does too. The test asserts the persisted value is actually cleared rather than the message merely hidden, because with no observers attached the panel drops to a different empty state and the toolbar goes with it. Worth recording the shape of the mistake: C1 was a good change that quietly moved a cost elsewhere. Persisting state preserves not only what the user wanted but what they forgot, and every state that reads persisted state inherits that. Console reviewed and unchanged — long output is capped, marked truncated and expandable. It stays the reference implementation. Full suite 7/7, verify:enhanced 19 checks.
Reviewing Time Travel turned up a regression my own C0 migration caused. It mapped bg-red-500 to bg-danger-surface everywhere, but a surface is a tint designed to sit behind text — on a 2px dot it is close to invisible. The recording indicator, its progress bar, and eleven other status dots across Compare, Observability, the profiler and the debugger were all quietly washed out. The distinction the migration missed: row and region tints want the surface, dots and bars want the foreground. Both are correct uses of the same ramp and only the second was broken, which is why nothing caught it — the token was right, the role was wrong, and neither the type system nor the lint rule can see that. 13 indicators restored, row tints left alone, and verify:enhanced now fails on any bg-*-surface sitting on a small round element or a bar fill. Time Travel itself needed no change. It says what it records before you start, and while recording shows frames against a bounded ring buffer — the cost is visible and capped, which is the reassurance that matters. Full suite 7/7, verify:enhanced 20 checks.
Closes the two questions left open on Performance. Is a capture's cost obvious before starting it? Partly, and no new work was warranted. Elapsed, Samples and Total show while a capture runs, and Performance carries an overhead panel with ms/frame and budget misses. Cost is visible during, not before; predicting it beforehand needs new instrumentation, which is a feature rather than a refinement. Does the panel distinguish cheap-to-leave-open from actively-measuring? Yes, explicitly — "Recording capture" against "Capture stopped". The question surfaced a consistency bug instead. The profiler marked recording green; Time Travel marked it red. Same concept, opposite colours, one product. Neither was right: a capture in progress is not "fine" — it costs frame time and waits to be stopped — and it is not a failure. Both use warn now, the state meaning "this is on and wants your attention", and keeping danger for real failures is what keeps danger meaning something. That is the same call made for rejected breakpoints in 6.4, which is the point: with a real semantic channel these questions get consistent answers instead of per-panel taste. Full suite 7/7, verify:enhanced 21 checks.
The command palette advertised "pages, plugins, snippets, sessions, docs" and could not find a single setting — 1,789 lines of them across four sections, reachable only by opening the dialog and hunting. Under the amended rule that is a promise half kept rather than a missing feature. Entries derive from settingsTabs rather than copying it, so a new section appears without anyone remembering. Section granularity is deliberate: enumerating individual toggles would drift the first time one is renamed and lands you in the same place anyway. Settings' security question needed no work. The MCP toggle, the one with the widest consequence, already says at the point of decision that it "allows local MCP clients to inspect and control live Feather sessions through a token-protected localhost bridge". Two things the existing tests caught, both from making the tabs controlled: - A persisted store written before this field rehydrates without it, and a controlled Tabs given undefined renders no section at all. Adding a field to a persisted store is a migration whether or not anyone calls it one. - Persisting the field made Settings reopen wherever you last finished. C1's own rule settles it: state belongs in storage if the user chose it and would be annoyed to choose it again, and a settings section is not that — you open Settings for a reason each time. It is now navigation for the life of the app run, which is enough for the palette to point at a section. About reviewed, unchanged: all four claims hold after the split. Full suite 7/7, verify:enhanced 22 checks.
Warnings rendered in the order the checks happen to run, which was actively unhelpful: "Session disconnected" is constructed first, so it sat above "Review security" every time. The page led with the thing you already knew — you can see the game is not running — and buried the one you did not. Sorted danger before warning now, stable within a tone so related checks stay grouped. With C3 having given all nine an action, the panel answers "what do I do?" in reading order. This closes the last surface on the board. All thirteen Inspector panels are either changed or reviewed and deliberately left alone with the reason recorded. Full suite 7/7, verify:enhanced 23 checks.
Inspector's board is closed, so Studio gets its section. It needs a different kind of work: Inspector was a working product with rough edges, Studio is not yet a product. Four verified facts: - StudioApp.tsx is still the Phase 05b placeholder, telling the reader the tools "move here in Phase 05c" while 25,500 lines of them sit in apps/studio/src/tools/ that the shell never imports. - The tools reference shadcn tokens 394 times; Studio's stylesheet defines none. A wired shell would render every surface and border unresolved. The showcase escapes this only by carrying its own 93-variable copy of Inspector's token block. - The tools branch on session state 24 times through isCreativeSession and friends — Inspector's model, where a gameless workspace was the exception. - Studio never creates one. So standalone Studio reads as "live mode with no game" rather than "local authoring", and reaches for a controller that needs a game. The showcase works around it by injecting its own and passing `standalone` by hand. The instructive part: Phase 05c was verified and passed. The LOC landed, the boundary held, every suite went green. Nobody checked whether the application could be opened, because the phase was defined as a move and the move was real. A migration complete by its own definition can still leave a product that does not exist. The design is one sentence — Studio is a local authoring tool that can optionally reach a running game — which inverts what the code assumes. Three consequences and five steps in dependency order: tokens below the boundary, shell and mounting, invert the session model, durable workspaces, then apply C0-C5. A Studio board tracks them.
Feather Studio ships 35,325 lines including 25,500 of working creative tools and renders none of them. StudioApp.tsx is still the Phase 05b placeholder telling the reader the tools "move here in Phase 05c". They moved; the shell was never updated. The specification is written to be picked up cold: every claim carries a file reference and was verified against the repository, every step names the files, the exact change, the acceptance line and how to check it. Writing it corrected the design in V4-ENHANCED §9, which had said Studio has no design tokens. It has them — a full theme registry and a ThemeProvider that writes every token onto the root element. What main.tsx mounts is HostProvider and nothing else. The showcase renders these same tools with four providers; Studio is missing ThemeProvider, QueryClientProvider and UiProvider. The fix is a provider stack, not a token system, which turns S1 from a package refactor into an entry point change. The session defect is also narrower than first described. Shader Graph already handles standalone correctly with `!sessionSupportsRuntime(activeSession)`. Texture Lab and Particle Playground invert it — `creativeSession ? localPlayground : livePlayground` selects the live controller when no game is attached, because Studio never creates a creative session. 13 sites across 7 files, each listed with line numbers. Also carries a traps section for the things that have already cost time here: tests that assert the placeholder they are meant to replace, verifying a test by breaking what it tests, extracting the judgement when the harness fights back, and adding a field to a persisted store being a migration. Linked from AGENTS.md and CONTRIBUTING.md.
Inspector's direction is "an instrument, not an interface": color as a reserved signal channel, typography for hierarchy, rules over cards. That is right for Inspector and wrong for Studio, which is worth saying because the two share a component library and sharing a look is the obvious move. They are different species. You read Inspector; you work on Studio. In Inspector the data is the product and nothing else in the window has color, so color can be spent on state. In Studio the artifact is the product and it is made of color, so every colored pixel of interface competes with the judgement the user is trying to make. The metaphor is a workbench: a neutral surface, the work in the middle, tools within reach but out of the way, good light. Six rules follow. W1 keeps the semantic channel from C0 but adds a rule above it — decorative color is out, and accent color does not appear near a preview. W2 is the one with teeth. Studio opens dark regardless of the system preference, because simultaneous contrast is measurable: a bright surround makes a texture read darker and more saturated than it is, and the user compensates in the file. Previews get a dedicated neutral ground the user can switch, defaulting to #767676 — not a taste, but the sRGB value whose relative luminance is 0.18, computed rather than picked. The checker values are deliberately close together, since a black/white checker is itself a strong contrast stimulus. W3-W6 cover direct manipulation over forms, feedback during the drag rather than on release, three regions visible at once rather than tabbed pages, and the artifact having a name. Adds S3b: Shader Graph already has the workbench shape, while Texture Lab is a page with a header and Particle Playground is built from Inspector's cards. Both become three-region workbenches with the preview in the centre. S1 gains the dark-by-default decision, S2 points at §5, and the definition of done gains a criterion for it.
Studio shipped 25,500 lines of creative tools and rendered none of them. StudioApp.tsx was still the Phase 05b placeholder announcing that the tools would "move here in Phase 05c"; they had moved, and only the shell was missing. S1 — the provider stack. main.tsx mounted HostProvider alone, so the tools had no query client, no UI dependencies, and no design tokens. Implementing it turned up a second half the spec had missed: `@theme inline`, which maps tokens onto Tailwind's color utilities and is what makes `bg-card` exist as a class, lived only in the showcase's stylesheet. Without it the classes are inert whatever the values are. It now lives in theme-tokens.css, shared by Studio and the showcase so they cannot drift on what a card looks like. S2 — the shell. A rail and a tool surface, per V4-STUDIO §5: a frame rather than a page, contributing no color of its own, with the tool owning the window. Which tool you had open persists. The Inspector connection sits at the edge as chrome, because its absence is not an error. S3 — the session model. Texture Lab and Particle Playground asked `isCreativeSession`, which is false when no game is attached, so they selected the *live* controller in exactly the situation Studio is normally in. Particles reported itself "not available in this session" on every launch. They now ask `useIsLocalMode`, and local is the default. The creative-session vocabulary is gone, along with the `standalone` prop and the showcase's `playgroundOverride` — both workarounds for the model being inverted. `standalone` turned out to conflate two things: "no game" and "I own the window". In Studio the second is always true, so the viewport measuring and the shrink/fill branching went with it. The four Studio e2e tests asserted the placeholder — its heading, its subtitle, its connect button — and passed for as long as Studio rendered nothing. Replaced with six that assert the properties: every tool reachable and substantial with no Inspector, the tools actually styled, the chosen tool surviving a reload, and a failed connection leaving Studio working. A showcase test also encoded the defect: it asserted Texture Lab's "use as shader preview" fallback label, which was only reachable because the tool was wired to the unavailable live controller. It can feed the emitter now, so it asserts the property instead. Two tests were verified by breaking what they test — the first version of the styling one passed with ThemeProvider removed, because the :root fallback resolved the tokens. It now checks the inline style the provider writes. Full suite 7/7.
Owner feedback on the first shell: the borders read as brutalist, and the side rail should hide and overlay rather than sit there. Both are right, and both are now design rules rather than one-off edits. W2b — regions separate by background value, not drawn lines, which is the VS Code approach. A border is a line you have to look at; a half-step of value says "different region" and then stops registering, which is the behaviour a workbench's chrome should have. Borders stay where they are an affordance: inputs, buttons, anything you click into. Applied to the shell, Texture Lab's header rule, Particle Playground's left panel rule, and its section cards — a card inside a tool that already owns the window was Inspector's idiom, where cards separated peers on a scrolling page. Here the section is already in its own region and the outline had nothing to do, so grouping comes from a value step and a quiet heading instead. W2c — navigation overlays rather than occupying. Three tools do not justify taking width from a node graph for the whole session, and W1 says the artifact gets the space. What stays permanently is a 36px strip naming the tool and whether a game is attached; the menu appears over the work when asked for. It closes on selection, because the one extra click to switch must not also charge a dismissal. The menu uses the existing Sheet primitive, so focus trapping, Escape and the overlay come from Radix rather than being hand-rolled. The active tool is marked with a left bar and a value step, the way an editor marks its current file. Studio's e2e updated for the new shape, including a test that the menu overlays and closes on selection. 7 Studio, 21 showcase, full suite 7/7.
The reported symptom was "strong border on every container" in Shader Graph. The cause was not a design choice. Tailwind v4 changed the default `border` colour to `currentColor`, and shadcn covers that with a base rule the showcase entry had but `studio.css` never did — so every `border` utility in Studio drew in the *text* colour. Measured 15.8:1 against the page where the token intends 1.25:1; 172 near-black outlines in Shader Graph alone. - Add the `border-border` base layer to studio.css, and drop ~9 lines of dead `.studio-panel` / `.studio-button` placeholder CSS the real shell replaced. It was hardcoding its own borders and body colour over the theme, so the body now takes `bg-background` and themes reach it. - Revert an earlier `--color-border` softening to 55%. It was compensating for the then-unknown currentColor bug; with borders resolving it read at 1.13:1, too faint to divide anything. The raw token matches Inspector, which serves the "doesn't feel like a single product" half of the report. - Name the idiom once: `.surface`, `.surface-inset`, `.section-label`. Replaces 59 boxed containers written 8 ways and 42 labels written 7 ways. Controls and anything carrying a state colour keep their borders — classified by the presence of h-*/cursor-/focus/hover:/disabled: or a palette colour. Tightening the spacing surfaced a real defect: a diagnostic's fix button ended up 59px under the floating LÖVE preview, unclickable. The preview now publishes its measured footprint as --floating-preview-gutter through a ResizeObserver (it is resizable, so a constant goes stale on the first drag) and the shader right panel reserves it. Both fixes are guarded by tests that were confirmed to fail without them. Also pass PLAYWRIGHT_REUSE_SERVER through turbo, so the documented escape hatch for "port 1430 already in use" actually works from `pnpm run test`.
The shader graph's preview node rendered Studio inside its own iframe. When the previews moved out of Inspector, the love.js Vite plugin was extracted with a docstring naming its consumers: "only Studio and the showcase consume this". The showcase was wired up; Studio never was. With no plugin and no publicDir, every preview iframe requested /showcase-lovejs/... , hit the SPA fallback, and got index.html back — verified byte-for-byte identical to the app's own shell. This affected the node probe (webgl.html) and the floating preview (index.html) alike, in shader graph and particles. - Wire loveJsPreviewPlugin into apps/studio/vite.config.ts, with the cross-origin isolation headers the showcase already sets. - Add scripts/prepare-lovejs-dist.mjs, which puts the preview target into any app's build output, and run it from Studio's build. The packaged app shipped without one: `studio:build` produced only index.html and assets/. The script anchors its root to its own location rather than process.cwd(), because pnpm runs a workspace script from that package's directory. - Track the love.js scripts in the studio and showcase e2e turbo inputs. Neither listed scripts/, so breaking the plugin would have replayed a cached pass. Neither player uses SharedArrayBuffer, so the packaged app does not need cross-origin isolation to start and Tauri's CSP is unchanged. Guarded by a test confirmed to fail without the fix: with the plugin removed, a fresh dev server returns the SPA shell for both entry points. Noted, not fixed: apps/studio/src/utils/assets.ts references /gif.worker.js, which Studio does not serve either. The module has no importers — it is dead code from the migration — so nothing reaches that path today.
Answers the report that particles need a better architecture and that the exported code must behave exactly as the preview. The second half is not a bug, it is the arrangement: particle semantics exist in three independent implementations — the in-game plugin (4609 lines), the love.js preview Studio shows (1237), and a ~950-line code generator that emits a fourth copy as string literals. Only the plugin shares timeline_runtime.lua, and the preview shares nothing; it defines its own easing, keyframe evaluation and emission gating. Shader Graph next door already requires one preview runtime. A verified consequence: the shared runtime clamps four values, the preview clamps none. The overshoot easings are supposed to leave [0,1], so a sizeScale lane with inElastic drives sizes to -37% in the preview and 0 in the export, from a stock easing with no warning. Pooling diverges the other way — it exists only in generated strings, so the export allocates differently than anything the author can see. The spec also corrects the premise it was asked under. LÖVE 11.7 simulates particles on the CPU in C++ and batches them into one GPU draw; there are no compute shaders, so GPU-resident simulation is not available. Per-particle work is already native and needs no change. The real costs are elsewhere and are measured: 8 lanes and 6+ pcall'd setters per emitter per frame whether or not anything changed, 73 pcalls in the plugin's hot path, atlas "variants" fanning one emitter into up to 16 systems and 16 draw calls, and no prewarm anywhere. P1-P5 put semantics in one module all three hosts require, add change detection so an unkeyframed composite issues no per-frame setters, move pooling and warmup where every host gets them, collapse the variants fan-out to a shader, and make the generator embed the shared module instead of restating it. No code changes. Every number cites the command that produced it.
Answers whether custom curves can stay without costing the frame. They can: a lane reads no runtime state, so it is a pure function of time and can be sampled once into a lookup table. Per frame it becomes an array index and a lerp instead of a keyframe search plus a 25-branch easing chain. Adds scripts/bench-particle-timeline, which loads the real production evaluator rather than a copy, so the numbers move when the code does. Measured in LÖVE's own LuaJIT, 600 frames, 8 keyframed lanes per emitter: 6 emitters 0.045 ms/f 1,660 KB -> 0.006 ms/f 1 KB 8.1x 50 emitters 0.351 ms/f 13,830 KB -> 0.051 ms/f 12 KB 6.9x This corrects the spec twice. First, §4.2 called per-frame lane evaluation "the real cost"; at 16.667 ms/frame, six emitters spend 0.28% of the budget and fifty spend 2.1%. Curves are not what makes frames late. Second, the allocation figures were measured with the collector running and swung 14x between runs. With GC stopped the real number is 1.38 MB/s at 50 emitters, against ~1 KB baked — and a composite whose lanes are all constant allocates exactly as much as a fully keyframed one, because the garbage is unconditional. That, not the microseconds, is what produces hitches. Accuracy is a resolution question and the rule falls out of the measurement: sample at least once per displayed frame and the baked curve is exact where the frames land. A hold step baked at 64 samples over 3s is 64% wrong; at 180 it is 0.000%. The spec settles on ceil(duration * 120) — two samples per frame at 60fps — costing 2.8 KB per lane, and constant lanes need no table at all. Also notes that jit.status() is false on arm64 macOS, so these figures are the interpreted worst case, which is the case a dev machine here is in. No behaviour changes. New §5.1 covers what baking does not change: the easings, what a keyframe means, the project file, and the exact evaluator the editor uses while a keyframe is being dragged.
Answers the two halves of the owner's note: a redesign of the authoring UI is open, and Hot Particles is the performance bar. Read Hot Particles' export template rather than its README, which is four lines. Its speed has a single explanation: the exported module has no update function. Twenty setters run once, and the consuming game calls ps:update and draw. Every animation it can express is a native per-particle interpolation stepped in C++. Per-frame Lua: zero. It is fast because its authoring model is LÖVE's ParticleSystem one-to-one — and that is also why it has no timeline, no bursts at authored times, and no multi-emitter choreography. So "the same performance" cannot mean matching it at what it does. §5.2 states it as: an effect pays only for the tier it uses. Tier 0 is native and compiles to what Hot Particles emits, with no runtime attached. Tier 1 adds the timeline at 0.006 ms/frame for six emitters. The tier is derived, never declared — and timelineLanePlan already classifies lanes as constant/linear/generic, then throws the classification away. §5.3 covers the authoring redesign: templates as the opening screen rather than a dropdown, the gizmos kept because they are the real advantage over a slider panel, the native property named beside each control so the export stays legible in the same vocabulary, and the timeline made a layer you add instead of a surface every composite carries whether or not it uses one. Also corrects §4.5, which claimed nothing is warmed. The mechanism already exists and the situation is worse than absence: kickStartSteps/kickStartDt are authored in Studio, applied in the plugin only behind a manual button, written into the exported table and never read there, and referenced zero times in the preview. The field names come from Hot Particles, which is where this data model was derived from. P3 now builds on init.lua:3079 rather than designing it. No code changes.
The preview showed one thing and the export shipped another. core.lua is now the only place that decides what a keyframe means, and all three hosts require it. The reported symptom: a sizeScale lane eased with inElastic drove sizes to -37% in the preview and 0 in the game and the export. The cause was not a bug to find — the semantics existed three times in Lua, and the preview's copy clamped none of the four values the other two clamped. - core.lua (327 lines): pure Lua, no love reference, no require. Core.resolve returns a frame's lane values already clamped, so no host can forget. Core.resolveSizes fills a caller-owned table, which P2 needs. - timeline_runtime.lua: 303 lines to 125, now the setter half plus coreSource(). Its semantics lived inside a [=[ ]=] string, so luacheck never saw them — the Lua lint goes from 146 files to 147 by moving them into a real file. - showcase_preview/main.lua: 1237 to 1080. 151 duplicated lines deleted and applyTimelineAt routed through Core.resolve, which is the actual fix. - The export embeds core.lua by reading it, and refuses rather than falling back to a stale copy. - Duration clamping unified: the plugin clamped to [0.25, 60], the preview only to a 0.25 floor, so a 90-second timeline looped differently in each. Placed in the plugin directory rather than packages/runtime-lua/particles/ as the spec said: generate-manifest.sh only walks feather/ and plugins/, so a new top-level directory would never install into a game. Found a fourth implementation while doing it — easing.ts draws the curve editor. It cannot require core.lua, so a generated contract pins it instead. All 26 easings already agreed; that divergence was latent, not shipped. Verified by breaking each test. Two findings: removing the clamps fails the Lua e2e as intended, and the export-equivalence test was vacuous as first written — it sampled six hand-picked easings and a deliberately drifted inQuad passed through it. It now iterates Core.EASINGS and asserts the count is 26. 755 Lua assertions, all 7 turbo tasks green.
A composite with no keyframed lanes now issues 6 setter calls instead of 3,600, and allocation on the per-frame path drops about 100x. Lanes compile once: constant lanes to a number, varying lanes to a baked lookup table at ceil(duration * 120) samples. The compiled track is cached against the track's own identity, so baking happens on edit rather than per frame. apply.lua compares each resolved value against what it last sent and calls nothing when nothing moved. Measured over 600 frames, 8 lanes per emitter: 6 emitters, curved 0.038 ms/f 368 KB 3600 calls -> 0.014 3.6 KB 3600 6 emitters, constant 0.015 ms/f 368 KB 3600 calls -> 0.008 3.6 KB 6 50 emitters, curved 0.330 ms/f 3066 KB 3600 calls -> 0.111 30.1 KB 3600 Baked curves match the exact ones where frames land: a hold step is 0.000000 off at 180 samples and 64% off at 64, which is why the resolution rule is tied to the frame rate rather than picked. pcall is gone from the per-frame path. apply.lua calls methods on the object it is handed and never touches the love global, so a counting stub substitutes for a ParticleSystem in tests; a guard fails if pcall reappears there. Two allocations found by measuring, not reasoning. The benchmark was rebuilding its own options table each frame — the test allocating, not the code. The real one was resolveSizes calling parseNumberList, which builds a table, per emitter per frame: 369 KB per 600 frames on the path meant to allocate nothing. Dropping pcall surfaced a behaviour fix: LOVE's setSizes takes at most eight values and a longer list used to raise inside the pcall, so no sizes were applied at all. core.lua caps at eight. Invalidation is wired where the properties are re-snapshotted and where the system is reset, since a change detector is wrong the moment something else writes the system. 771 Lua assertions, all 7 turbo tasks green.
kickStartSteps and kickStartDt were authored in Studio, written into the export and never read there, applied in the plugin only behind a manual button, and absent from the preview entirely. So an ambient effect began empty everywhere it mattered and filled over a particle lifetime, and its first seconds never looked like the steady state it was tuned against. lifecycle.lua now owns warming and pooling. Neither touches love — warming takes an updater function, pooling takes a factory — so both unit-test without a graphics context. - The plugin warms in resetTimelineSystems. - The preview warms in resetParticleSystems, and now carries kickStartSteps and kickStartDt through the payload at all; Studio was already sending them. - The exported file gains warmEmitter and calls it where an emitter starts. The rule stays in one place and the export carries the answer: _generateCode asks Lifecycle.warmStepsFor at export time and emits the resolved warmSteps and warmDt as literals, so the generated file runs a loop rather than deciding anything. An authored kickStartSteps always wins, including an authored zero — turning warmup off is a decision, not an absence. Otherwise ambient warms one particle lifetime and loop/one-shot do not. Verified on a real LOVE particle system rather than asserted: unwarmed holds 0 particles at frame 1, warmed holds 208, and the same system left to settle for 600 frames holds 213 — frame 1 within 2.3% of steady state. Two guards on the loop itself: a runaway step count is capped, because a hang is not a warmup, and a large dt is clamped to 1/10 — a huge step teleports past the particle lifetime and leaves the system emptier than not warming at all. 799 Lua assertions, all 7 turbo tasks green.
The specced fix was not possible. This plan said picking a random atlas frame
per particle is "a shader's job" — a shader cannot do it, because it has nothing
to key the choice on. LOVE 11's ParticleSystem exposes 68 methods and not one
provides per-particle data to a shader: no attachAttribute, no custom vertex
attributes, no seed. Verified by enumerating the metatable. That capability
belongs to the mesh-and-instancing backend, not this one.
So the fan-out stays and its cost changed instead — and the draw calls turned
out not to be the expensive part. Measured on a real build, same particle count
throughout: 16 systems cost 2.5x one system (0.301 vs 0.119 ms/frame). Real, but
not the problem.
The problem was keeping the variants in step. copyParticleProperties ran ~20
property copies per variant per frame, each allocating a closure and a result
table — 320 of each per emitter per frame, which quietly undid P2.
16 variants, 600 frames before 0.037 ms/f 45,750 KB
after 0.022 ms/f 5,100 KB
1.7x faster, 9x less garbage, and change detection now gates it entirely:
applyTimelineToEmitter returns its setter count and the caller skips the sync
when nothing moved.
The copy moved to lifecycle.lua, which made it testable, and the first test
written against it found a bug: getLinearAcceleration returns four values and
the rewrite copied two, silently collapsing the max bounds onto the min. A
spread of accelerations became a single acceleration, on variants only,
invisible unless you compared a variant against its source. Twenty-four
assertions now cover every copied property.
Still open deliberately: one emitter is still N draw calls under variants
playback. That needs per-particle attributes.
823 Lua assertions, all 7 turbo tasks green.
The export now embeds core.lua and apply.lua verbatim and calls them. It defines none of the semantics itself. Removed from the generator: 173 emitted lines of mode normalization, clamping, a keyframe evaluator, clip gating and a bespoke setTimelineValue change detector, replaced by 40 that wire up the shared modules. A further 121 lines of buildTimelinePlan / luaTimelinePlanTable / timelineLanePlan went with them — the exported file carries the authored timeline and calls ParticleCore.compileTrack at load, the same compile the editor runs. The conditional embed is gone, and it was a real defect: core.lua used to be embedded only when a lane needed the "full" evaluator, and otherwise the file got a cheaper inlined sampler. Simple effects shipped a different evaluator from the one the editor showed them. _generateCode now contains no easing maths, no keyframe evaluation, no clip gating, no clamping, no change detection and no lane names — checked mechanically and guarded by a test that fails if any return. Tier 0 is real rather than decorative: with no varying lane the export omits the compiled tracks, the per-frame apply and the embedded applier entirely. Asserted both ways. Verified frame for frame: the test loads the exported file's own timeline table, compiles it with the exported file's own embedded core, and compares every resolved lane against the live core across 241 frames — 2,169 comparisons, zero mismatches. Confirmed non-vacuous by perturbing the embedded opacity clamp, which produced 241 mismatches. Several e2e assertions encoded the plan shape this removes and were rewritten to assert properties instead. One of them revealed that the composite the tests called "simple" is tier 1, not tier 0 — "simple" meant simple easings, a distinction P5 deletes. 826 Lua assertions, all 7 turbo tasks green.
It was committed by accident — a git add -A swept up a file that was meant to stay out of the tree while it is handed to another agent. The file stays on disk; .gitignore now keeps it from being re-added the same way.
Studio had 86 raw Tailwind colour literals expressing the same four meanings Inspector had already solved, none of them reachable by the shipped themes. The derivation is shared rather than ported. packages/ui/src/theme/semantic.ts owns the contrast-solving and both apps require it; hand-copying 211 lines of it is how divergence starts, and the apps cannot import each other, so the package was the only honest home. Each keeps a four-line shim naming the result in its own ThemeVariables. 82 literals became tokens. The remaining four are identity, not state, and say so with an inline disable: Texture Lab's move and resize handles, told apart by shape and colour over arbitrary texture content, and Shader Graph's violet Probe badge, which marks what a node is rather than that something is wrong with it. The C0 regression recurred and was caught: a mechanical pass painted both drag handles with surface tokens — a pale region tint on a 12px solid mark, which is invisible. verify:enhanced now checks for that exact shape. Acceptance met both ways. verify:enhanced gains an S5 section with five checks, and the no-restricted-syntax rule now covers apps/studio/src — confirmed to bite by reintroducing a literal. themeSemanticColors.test.ts additionally asserts every Studio theme clears WCAG AA for all four states against page, card and its own surface, which is the check that caught 66 failing combinations the first time this was done in Inspector. Two findings recorded rather than fixed: the two theme registries are otherwise byte-identical — one registry maintained twice — and consolidating the rest is a separate change. Also removed Studio's copy of createGif, which had no caller here and pointed at /gif.worker.js, a path Studio does not serve. An earlier note in this session called the whole module dead; that was wrong — fetchBlobAsUint8Array beside it is live, and only the dead half was removed. All 7 turbo tasks green.
C1 turned out to be satisfied already, and saying so is the finding. Studio persists the active tool, user preferences including collapsed palette categories, and the shader graph. A survey found exactly one un-persisted filter — the node palette's search box — and it should stay that way: you type it to find a node and immediately drag that node out, so persisting it would reopen the palette pre-filtered, which is the trap C1 itself warns about. C3 was mostly satisfied too. Of 15 error and empty states, most already name the action, and "No composites yet" has a New Composite button directly beneath it. Two did not: - the node picker's "No nodes found" offered no way out; it now says the search is filtering and that clearing the box shows everything - three connected-game preview failures reported what had not happened but not what to do; they now name the likely cause and the thing to check Studio e2e 9 passed, showcase 22 passed, lint and typecheck clean.
Ten of the eleven theme files were byte-identical between Inspector and Studio — 4,602 duplicated lines — and the eleventh differed only because C0 had landed in one of them. One registry maintained twice, which is the same shape as the particle semantics: the copy that has not drifted yet is the one about to. packages/ui/src/theme/ now holds all of it: registry/ (79 themes across five families), dark.ts, light.ts, semantic.ts, and the NOTICE.md attribution that belongs with them. Both apps import @feather/ui/theme/registry and neither has an assets/theme directory any more. 4,824 lines deleted, 45 added, across six import sites. With types.ts now a sibling of semantic.ts, the generic signature the split had forced goes away too — withSemanticColors names AuthoredThemeVariables and ThemeVariables directly again. Guarded both ways: verify:enhanced fails if either app grows an assets/theme tree back, and themeSemanticColors.test.ts asserts the same. The Studio-specific WCAG test collapsed into the shared one, because there is now a single registry to assert against. All 7 turbo tasks green.
Four documents drive V4 and each buries its open items in its own detail. This is one place that says what is not done, checked against the repository rather than recalled. What it records: five release dry-runs blocked on push access and not on code; Studio's S3b and S4 plus C2 and C5; the particles §5.3 authoring redesign, which the five runtime phases deliberately did not touch; and the one definition-of- done item that cannot be met on LOVE 11 at all, because ParticleSystem exposes no per-particle data to a shader. Two claims were written and then checked before committing, which changed them: - The two components/ trees are NOT copies — Inspector has 17 files, Studio 1, and none overlap. The draft asserted the opposite; checking took one command. - The real remaining duplication is three files and 172 lines (arrays.ts, cache.ts, timers.ts), all byte-identical, none drifted. Worth sharing, but nothing like the 4,602 lines the theme registry was. Also marks S1-S3 done in V4-STUDIO.md. They landed in c95b272 and the headings were never updated, so the spec read as though Studio had not been built — verified against the code, not the commit message: the provider stack in main.tsx, the shell in StudioApp.tsx, studioModeFor in session/index.ts. The last section records the practices this cycle learned the hard way, including the two mistakes that produced the corrupted spec and the file committed by accident. Both trace to staging without looking.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.