Repository navigation
Untangle the codebase - #39
Conversation
The capture-height cap, the parked-pointer sentinel, the void gradient, the spaghetti tint the renderer branches on, the fx view type and the slow-upload decision were each written out in two or three modules that had to agree without anything tying them together. Each now lives with the module that owns it and the others import it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine silenced held-tool loops by naming water, saw, flamethrower and void itself, apart from the if-chain in audio.ts that configured them. A table of loop voices now holds the filter settings and whether a loop belongs to a held tool, and the engine fades whatever the table marks as held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine copied, measured, restored, erased and cleared the overlay's damage canvas by hand, and rebuilt the fx transform from the overlay's offset fields on the promise that positionFx had already run. Those operations are now Overlay methods, so the canvas layout can change in one file. Post-FX and heat-field sizing, written out twice, is one method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decals.ts cached each context's dpr forever, assuming targets keep one transform for life. The overlay and the content layer both re-pick dpr on resize or recapture (pickPixelRatio drops to 1.5 past 4500px), and after that stamps were rasterised at the stale scale. A dpr change always resizes the canvas, so the cache is now keyed on the canvas size as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
drawBrittleFracture, drawDent and drawSurfaceScore had no callers, and neither did PerformanceMonitor.setQualityReason. WOOD carried flammability and burnRate of 1 (pure multiplications by one), an unread colour, and a restitution that Math.max always overrode; its toughness check in the gun made a crack branch unreachable. Those knobs and the branch go, along with the test that only restated the knob values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The measured bounds of the base and heavy tool art sat in a table in toolart/icons.ts, which therefore imported every drawing; the toolbar imported icons through toolart/index, and the base tools imported their art through the same index. So the toolbar and the base tools chunk both pulled in the heavy ordnance art. Bounds now register beside each drawing (as the advanced art already did), and those modules import only the art they use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
default-tools.ts and lazy.ts each spelled out "base tools minus the last, then heavy, then advanced, then the last base tool", and the lazy loader's doc promised a gravity tool it does not load. Both now call officialToolOrder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each ready-made toolbar built its own engine, registered tools by hand instead of through mountRageLayer, built a ToolbarModel, wired the window shortcut listener, saved and restored focus, and computed roving-focus arrows with its own index wrapping (Vue without the empty-toolbar guard). openToolbarSession and rovingFocusTarget now do that once; each adapter keeps only its rendering and its own defaults. The two refcounted <style> injectors share one helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine derived the frame-skip interval and the target frame rate from the monitor's rate-cap state and borrowed the balanced profile to do it, while the monitor made the rate-cap decision itself; the engine also re-checked quality mode on recommendations the monitor only makes in auto mode. The monitor now answers both questions per tier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Narration such as "Dark torn core." above the code that draws it, a 13-line paragraph in live.ts duplicated from the paint() doc, and a comment in the engine constructor copied from capture-controller.ts that described unrelated code. Comments that record browser behaviour, measured costs, call-order hazards and design intent stay, as does all public JSDoc that ships in the .d.ts files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coverage guard asks for this once the module's line coverage reads correctly again (it now reports 95%), so the exemption no longer hides anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- sdk: the isolation test bumped a counter it never read; it now checks that two instances keep separate state and that reset starts fresh. - toolart: the cache test compared two deterministic renders, which match with or without a cache; it now checks no canvas is created on a repeat request. The bounds test covers every built-in drawing, not just the hammer. - toolbar: "disposing the engine tears the model down" only checked that reading state did not throw; it now checks the model stops notifying. Adds a test for roving focus, including an empty bar. - toolsets: re-derived the default order formula and hard-coded set sizes; it now checks the behaviour (every set once, hammer first, broom last) and that the lazy loaders return copies. - engine: the loop-silencing test depended on iteration order. - content: removed a test that only asserted a deleted method is absent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
snapshot() cut the fx (or post-FX) canvas with the page's dpr, but that canvas is backed at the effects ratio (min(dpr, effectsPixelRatio), 1 by default). On a 2x display every exported snapshot carried a half-size, shifted effects layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine re-implemented Overlay.dispose() inline (missing its damaged flag reset) and reset the fx offsets by writing the overlay's fields, which is what invalidateFxTransform() already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isParked() kept the old "x < -100" heuristic, which also caught real positions left of an offset container. The parked position is always assigned exactly, so compare with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared style helper had renamed the Vue <style> marker; it now takes the attribute value too, so Vue keeps data-rage-layer="toolbar". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gravity gun drives the "void" loop while held, but the singularity step faded that loop on every frame with no singularity, so the hum never sounded. The step now fades it only on the frame its own singularity ends. The fire crackle's filter jitter moves into the loop voice table instead of an if on the loop name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qualityProfile was a second field that had to be reassigned whenever qualityTier changed; it is now a getter over QUALITY_PROFILES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The measured-bounds note was copied into two art modules; heavy and advanced art now point at the one in toolart/base.ts. paintVoid shares the void layer's colours, not its exact geometry, and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gun, flamethrower, paintball and black hole still tested the parked pointer as "x <= -100" beside the shared helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comments pass dropped a call-order rule in content.ts (recorded decals must land before a rebuild), the memory reason for clearing decal-log references, and the second half of the rocket's note on what happens when its target has been destroyed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine now asks the monitor for the frame interval and target rate, so the rate-cap flag was read only by tests. They assert the interval the cap produces instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
snapshot() was the last place the engine read the damage canvas and its pixel ratio directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session now does teardown for all three toolbars, so a test checks that closing it stops shortcuts, disposes the engine and returns focus. onClose is optional like the model's, so React passes its prop straight through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
drawDamageInto replaced the getter's last caller. The session test now removes the elements it adds and closes the session even when an assertion fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review Summary: ❌ FailedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
bun audit failed on main as well (vite, vue/server-renderer, happy-dom, source-map-js, shell-quote, launch-editor advisories published after the last green main run). Bump vite to 6.4.5, happy-dom and its registrator to 21.0.4, pin vue to 3.5.44 (also for vitepress's nested copy), and override the transitive source-map-js, shell-quote and launch-editor to patched releases. All are dev-only; no runtime dependency changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before the LOOP_VOICES table, SoundEngine.loop fell through to the saw voice for any name it did not special-case. The table lookup threw a TypeError instead, which an untyped custom tool calling engine.sound.loop() with its own name would hit inside the frame loop. Fall back to the saw voice again, with a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independent pre-merge reviewVerdict: MERGE AFTER CI. I pushed three fixes ( CI triage (run 42364880027, head
|
| Check | Result | Cause | Action |
|---|---|---|---|
| Lint, typecheck, test, build | fail at Dependency audit (bun audit, 14 advisories in vite, happy-dom, vue/server-renderer, source-map-js, shell-quote, launch-editor) |
Already failing on main, not caused by this PR. I checked out main @ 4e27d35 and bun audit lists the same 6 packages. The advisories were published after the last green main CI run (2026-09-05). This PR does not touch package.json or bun.lock. |
Fixed anyway because it blocks every PR (9a5903b): vite 6.4.5 (dependency + override), happy-dom and global-registrator ^21.0.4, vue pinned to 3.5.44 (override also covers vitepress's nested 3.5.41), overrides for source-map-js ^1.2.2, shell-quote ^1.11.0 and launch-editor ^2.14.2. All are dev-only; runtime deps and the published package are unchanged. bun audit now reports no vulnerabilities. |
| Chrome runtime suite / Node 20, 22, 24 consumer build / nextjs, nuxt, sveltekit example build | skipped | needs: verify |
Ran locally (see below) |
| CodeQL, Analyze | pass | ||
Lint, typecheck, test, build (run 42375848839, head fc3d84c) |
audit passed; Unit tests then failed: 9 Vue component tests with null is not an object (doc.createComment) |
Order-dependent test bug that was already on main. Bun shares one module cache across test files, and adapters.test.mjs statically imports vue without the DOM harness. When CI's file order runs it first, @vue/runtime-dom caches a null document. Running CI's file order locally fails 9 tests on main @ 4e27d35 and on fff2af0, so it's not caused by this PR. The first run never got past the audit, so it was hidden there. |
Fixed (eb74b2c): load the DOM harness and import Vue dynamically after it, as vue-component.test.mjs already does. CI's order and 3 random orders now pass (413/413). |
Branch protection: the PR is BLOCKED by the required status checks rule on main. The required checks are "Lint, typecheck, test, build", "Chrome runtime suite", "Node 20/22/24 consumer build" and "Analyze JavaScript/TypeScript", with strict "up to date" enabled. The branch is 0 commits behind main. Required reviews = 0, there are no unresolved conversations, and linear history is required (squash or rebase merge).
Findings
- Fixed (
fc3d84c), minor regression:SoundEngine.loop()with a name outside the 5 built-in loops now threwTypeError(LOOP_VOICES[name]is undefined). Onmainthe if-chain gave any unknown name the saw voice.engine.sound.loopis part of the publicSoundApihanded to custom tools, so an untyped JS tool would have thrown inside the frame loop. It now falls back to the saw voice. Added a test, which fails onfff2af0and passes now. - Noted, not changed:
isParkednow matches only the exact-1000sentinel.maintreatedx <= -100as parked in the tools andx/y < -100in pointer-input. While the pointer is captured and dragged more than 100px past the overlay's left or top edge, tools now keep firing (anddx/dyare no longer zeroed) where they used to stop. The PR declares this change, and I think it's more correct, but it is a behaviour change. - Noted, not changed: the Vue toolbar's style injector now uses the shared helper, which skips injecting when another module copy's
<style data-rage-layer="toolbar">already exists. With two bundled copies of RageLayer, unmounting one copy's toolbar can remove the CSS from the other. React already behaved this way onmain, but it is new for Vue. This only happens with duplicated module copies.
Toolbar session (React, Vue, element): I compared it line by line with main.
mountRageLayer({ initialTool: null })behaves the same as the oldnew RageLayerEngineplusregisterToolloop.setTool(null)on a fresh engine returns early, and an unknowninitial-toolis still a no-op.- Engine options,
toolStyleandhistorydefaults are preserved, and the element no longer leakstools/stringsinto the engine options. - Teardown removes the window keydown listener, destroys the model, disposes the engine and restores focus only if the previous element is still connected.
- In React, focus is now captured when the session opens rather than in the
readyeffect. Both points come before the toolbar takes focus, so the same element is captured. - In a headless-Chrome run of the built
<rage-layer>, I did 3 mount/unmount cycles. Each one: focus moved into the bar, shortcut2selected the gun, removal disposed the engine,rageLayerEnginebecamenull, focus returned to the trigger, no overlay was left over, and Escape after removal did nothing. The result is identical to the same run againstmain's build.
Exports and bundle: runtime export names are identical to main for all 12 entries, and importing on Node 20, 22 and 24 works (SSR-safe). Static-import closure, gzip: ./toolbar 13.7 KB → 5.6 KB; . and ./svelte slightly smaller; ./react +0.2 KB, ./vue +0.2 KB, ./element +0.8 KB and ./lazy +0.2 KB (shared session/tool-order chunks). All are within the check:package budgets.
Local verification (head eb74b2c)
bun install --frozen-lockfile,audit,lint,typecheck,check:artifacts,test:coverage(413 pass, also in CI's file order and 3 random orders),build,typecheck:consumers,check:package,docs:build: all pass.- Node 20/22/24 consumer job: consumer typecheck,
check:packagewithnpm_config_ignore_scriptsand the Node import smoke test pass on Node 20.20, 22.23 and 24.22. - Example builds: pack,
bun install --frozen-lockfile, install the tarball,cmpdist and build all pass for nextjs, nuxt and sveltekit. - Chrome job: benchmark smoke (
idle,mixed --assert) and the memory leak gate (--leak-cycles 20) pass with chrome-headless-shell 157.scripts/browser-test.mjsitself still hangs at "demo page to finish booting" on this macOS host, onmainas well. CDPRuntime.evaluatenever returns while the harness polls during boot, so it's a harness/host problem, not this PR. CI is the source of truth for that check.
Needs manual testing
- React and Vue toolbars in a real app: open, shortcuts, arrow/Home/End roving, close, and check focus returns. React StrictMode double-mount too.
- Snapshot export on a 2x display with effects visible (fxDpr fix).
- Gravity gun hum while held (singularity fix).
- Decal crispness after a resize on a page taller than 4500px.
- Docs site dev server, since vitepress now runs on vite 6.4.5 / vue 3.5.44 (
docs:buildpasses).
🤖 Generated with Claude Code
Bun shares one module cache across test files, and @vue/runtime-dom caches `document` when it first evaluates. adapters.test.mjs imported vue statically with no DOM, so whenever CI's file order ran it first, every later Vue component test failed with "null is not an object (doc.createComment)". The same order fails on main. Register the DOM harness and import Vue dynamically after it, as vue-component.test.mjs already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome 157 stable supports the live capture API, so auto mode reports "live" instead of "snapshot" in CI. Fails the same way on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
A structural pass over RageLayer, following one question: should these pieces change together? Values and logic that had to agree across modules now have one owner, the engine stops reaching into the overlay's canvases and the monitor's policy, the three ready-made toolbars (React, Vue,
<rage-layer>) run from one framework-neutral session, and the toolbar entry no longer pulls in heavy tool art (dist/toolbar/index.js13.3 KiB to 5.4 KiB gzip). Three pre-existing bugs were fixed along the way (snapshot effects layer at the wrong pixel ratio on 2x displays; decal stamps at a stale dpr after a resize or recapture; the gravity gun's hum muted every frame). Then comments that only restated the code were removed, and tests that could not fail were rewritten.Public API is unchanged. Every entry's runtime export list is identical to
main, and the bundled.d.tsfiles differ only inprivatemembers and in comments on private members (listed below). No runtime dependencies were added. Docs paths are untouched.Problems found (before any change)
src/react/RageLayer.tsx:112,src/vue/RageLayer.ts:57andsrc/element.ts:253each build an engine withnew RageLayerEngine+ aregisterToolloop (bypassingmountRageLayer), build aToolbarModel, wire the window shortcut listener, save/restore focus, and tear down in their own order.ToolbarModel.setFocusIndexalready wraps; Vue lacks the empty-bar guard (% 0).src/react/toolbar-styles.ts:202andsrc/vue/styles.ts:173each implement a refcounted<style>injector.MAX_CAPTURE_HEIGHT = 12000is defined in bothsrc/engine.ts:64andsrc/capture.ts:48.-1000is written inpointer-input.ts,engine.ts,heavy-tools.ts,advanced-tools.ts, and tested as< -100in two files and<= -999/> -999in the engine.overlay.ts:121(CSS) andengine.ts:848(snapshot canvas).FxViewis declared twice (overlay.ts:32,fx-render.ts:75).fx-render.ts:645branches onp.color === "#c98bff", a literal set inengine.ts:2160.surface.ts:383,postfx.ts:257andengine.ts:2365each compare the upload probe withSLOW_UPLOAD_THRESHOLD_MSthemselves.engine.ts:1601silences the tool loops by naming water, saw, flamethrower and void, apart from the if-chain inaudio.tsthat configures each loop name.engine.ts:693, 724, 742, 777, 997, 1011), rebuilds the fx transform from the overlay's offset fields (clearFxCanvas, which only works ifpositionFxran first), and re-implementsOverlay.dispose()inline.engine.ts:1643, 1704).decals.ts:47caches each context's dpr forever ("targets keep a fixed dpr"), butoverlay.tsandcontent.tsre-pick dpr on resize/recapture.snapshot()reads the fx/post-FX canvas withoverlay.dpralthough it is backed atoverlay.fxDpr.drawBrittleFracture,drawDent,drawSurfaceScore(decals),PerformanceMonitor.setQualityReason.WOOD.flammabilityandburnRateare 1 (multiplications by one),WOOD.coloris unread,Math.max(0.14, WOOD.restitution)is always 0.14, andWOOD.toughness < 2makes the gun's crack branch unreachable.toolart/icons.tsimports every base and heavy drawing to fill a bounds table, andtoolbar.ts/tools.tsimport art throughtoolart/index, so the toolbar and base-tools chunks pull in heavy ordnance art.default-tools.ts:7andlazy.ts:28both spell out the official order formula; the lazy loader's doc promises a gravity tool it does not load.qualityMode === "auto"on recommendations the monitor only makes in auto mode.qualityProfileis a second field that must be reassigned withqualityTier.stepSingularityfades the "void" loop every frame with no singularity, muting the gravity gun that drives the same loop.audio.ts:337has anif (name === "fire")jitter special case inside the generic loop updater.sdk.test.mjsisolation test never reads its counter;toolart.test.mjscache test compares two deterministic renders;toolbar.test.mjsdispose test only checks that reading state does not throw;toolsets.test.mjsre-derives the order formula and hard-codes set sizes;engine.test.mjs:383depends on loop iteration order;content.test.mjs:292asserts a deleted method is absent;tools.test.mjs:50restates the WOOD constants.Fixes, with the problems they solve
src/toolbar-session.ts):openToolbarSessionmounts throughmountRageLayer, builds the model, owns the shortcut listener and focus return;rovingFocusTargetowns arrow navigation. A sharedsharedStylehelper refcounts<style>tags.MAX_CAPTURE_HEIGHTfromcapture.ts;PARKED_POINTER/isParkedinmath.ts(exact sentinel match);paintVoid+VOID_STOPSinoverlay.ts;FxViewfromoverlay.ts;SPAGHETTI_COOL/WARMinfx-render.ts;canvasUploadsAreSlow/uploadsProbedFastingl.ts.FxView, colour-keyed branch, threshold comparisons.LOOP_VOICESwith filter, jitter and aheldflag); the engine fadesHELD_TOOL_LOOPS; the singularity fades "void" only on the frame it ends.snapshotDamage,restoreDamage,damagePixelCost,eraseDamage,clearFx,dispose,invalidateFxTransformare Overlay methods; post-FX/heat sizing is one engine method.clearFxCanvascall-order dependency; inline copy ofOverlay.dispose; duplicated heat sizing.overlay.fxDpr).setQualityReason, constant WOOD knobs and the unreachable crack branch.officialToolOrderused by both eager and lazy default sets.minFrameIntervalMs(tier),targetFps(tier));qualityProfileis a getter.Found by the check-and-repeat passes (subagent hunts), and fixed
snapshot()read the fx/post-FX canvas withoverlay.dprinstead ofoverlay.fxDpr(half-size, shifted effects in snapshots on 2x displays).stepSingularityfaded "void" every frame, muting the gravity gun's hum; it now fades only when its singularity ends.Overlay.dispose()and wrote the overlay's offset fields instead ofinvalidateFxTransform(); snapshot read the damage canvas directly (drawDamageIntonow).isParkedkept the< -100heuristic and four tool ticks still used<= -100; all use the exact sentinel now.<style data-rage-layer="toolbar">; restored.if (name === "fire")jitter moved into the loop voice table;qualityProfilebecame a getter;rateCap60andOverlay.damageReadylost their last callers and were removed.Deliberately not changed (and why)
2147483001vs engine default2147483000: tying them would change behaviour for hosts that pass a customzIndex.index.ts, entry files),COMBO_DEFINITIONSvsapplyCombo,ContentLayerpass-throughs,engine.random(): public API that must stay identical.engine.test.mjsscheduler test that drives private fields: rewriting it needs a scheduler harness.What was removed, and why
Comments (net about 140 lines across 18 files, commit
081078a, three restored later): only comments that restated the code below them (for example "Dark torn core." above the drawing code, section narration in tool handlers, a 13-line paragraph inlive.tsduplicated from thepaint()doc, and a comment in the engine constructor that had been copied fromcapture-controller.tsand described unrelated code). Comments about browser behaviour, measured costs, call-order hazards and design intent were kept. All public JSDoc that ships in.d.tswas left byte-identical; a script confirmed the comments commit changed no code.Tests (412 before, 412 after):
tools.test"all base tools share one immutable wood physics contract" (restatedWOODconstants, two of which were removed as no-ops);content.test"freeze-specific melt support is absent" (asserted a deleted method is missing).sdk.testisolation (never read its counter),toolart.testcache (two deterministic renders match with or without a cache; now counts canvas creation) and bounds (all built-ins, not just the hammer),toolbar.testengine-dispose teardown (only checked that reading state does not throw),toolsets.test(re-derived the order formula and hard-coded set sizes),engine.testloop silencing (depended on iteration order),performance.test(reads the frame interval instead of the removedrateCap60flag).scripts/check-coverage.mjs: removed thepointer-input.tslcov exemption, which the guard itself flags once the module's coverage reads correctly (95% now)..d.tsdiff againstmain(whole list)Verification (baseline
main@ 4e27d35 vs this branch)bun install --frozen-lockfilebun run auditbun run lint(Biome)bun run typecheckbun run check:artifactsbun run test:coveragebun run buildbun run typecheck:consumersbun run check:package(publint, attw, gzip budgets)bun run docs:build.d.tsnode scripts/browser-test.mjs(as written)benchmark.mjs --scenarios idle,mixed --assert--leak-cycles 20)Needs manual testing
<rage-layer>toolbars in a real app: open, use keyboard shortcuts (1 to 0, P, R, M, Escape), arrow keys in the bar, close, and check focus returns to where it was. All three now shareopenToolbarSession.bun run demo:toolsto review the tool reel (no automated check judges tool art).scripts/browser-test.mjs), which could not run as written on this machine.Overlap with open PRs
mountRageLayer(... tools, initialTool: null); this branch does the same throughopenToolbarSession, so resolving means keeping Fix engine lifecycle cleanup and host API boundaries #36'srunSafelyand lifecycle changes and taking the session here. Both touchengine.tsdispose()(this branch callsoverlay.dispose(); Fix engine lifecycle cleanup and host API boundaries #36 movesemit("dispose")to the end),pointer-input.ts(imports andisParkedhere vsend/strikebodies there),performance.ts(setQualityReasonandrateCap60removed here vs listener loop there), andtoolbar.ts(import line here). Fix engine lifecycle cleanup and host API boundaries #36'stests/toolbar.test.mjsadditions sit near the rewritten dispose test.src/react/useRageLayer.ts, scripts and fixtures, none of which this branch changes; no conflict expected.package.jsononly; no conflict.🤖 Generated with Claude Code