refactor(editor): extract the painters to src/draw.js (R2, step 9b) - #157
Conversation
src/main.js 19,963 -> 19,340. draw.js holds the lane and grid backgrounds, the
beat bar, the section-coverage strip, the fretted and piano-roll note painters,
the cursor and marquee, the song-key highlight state those painters consult, and
canvasH. Graph stays acyclic: draw -> {canvas, geometry, keys, lanes, notes,
theory, state}.
NO DI SEAM. `drawNow()`, the per-frame orchestrator, stays in main.js and calls
into draw.js, so the edge is one-way. Its ~14 calls into other sections (tempo
map, drum editor, parts view, the tone/anchor/handshape lanes, the loop strip)
never cross the boundary. `drawWaveform` stays with it: it paints the onset-strip
and bookmark overlays behind `typeof` guards, and those guards exist for sliced
test sandboxes, not because the overlays are optional — moving drawWaveform would
have silently stopped drawing them.
Rides along, each to where it belongs:
- the suggested-position mark WeakSet -> notes.js (it is note metadata, and
_drawNote reads it)
- MIN_NOTE_W / NOTE_PAD -> geometry.js (note-body geometry, shared by the
painters and by hit-testing)
- _coverageEditGen -> state.js, renamed `editGen`. It was never a drawing
concern: THREE memos key on it — the coverage strip (draw.js), the
chord-at-cursor readout and the drum-limb lint (both main.js) — because an
in-place note-time move keeps the notes array's identity AND length, so a
cheap cache key cannot see it. A counter cannot be written across a module
boundary, so EditHistory._afterEdit() calls the exported bumpEditGen().
Tests: five more off the slicer path. section_coverage imports the real pure
helper (draw.js loads under node — that is what the DPR typeof-guard in step 9a
bought). suggest_position_persist joins move/wiring in injecting the real
WeakSet + accessors; each of those envs builds fresh note objects, so an
identity-keyed module-shared WeakSet cannot leak marks between cases.
key_highlight_hoist keeps its source-shape assertions and reads src/draw.js.
TWO REAL BUGS, both caught by the headless harnesses while node --test stayed
86/86 green:
1. main.js still used MIN_NOTE_W / NOTE_PAD in hit-testing after they moved to
geometry.js, and I had not imported them: `NOTE_PAD is not defined` on every
mousemove. A crash — loud, but invisible to the unit suite.
2. Worse, a SILENT one. Two `typeof _coverageEditGen === 'number' ? ... : 0`
reads survived in main.js. With the counter moved away and not imported,
typeof yielded 'undefined', so the chord-display and drum-lint memos would
have keyed on a constant 0 and never invalidated on an edit. No test, and no
harness, would have caught that; a free-identifier scan of main.js did.
Verified: node --test 86/86, pytest 248/248. No unused import, no dead export, no
dangling @pure marker. All five headless Chromium harnesses green — draw path,
state round-trip, hit test, resize, piano roll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR migrates rendering/painter logic from src/main.js into a new src/draw.js module, moves note-body geometry constants into geometry.js, relocates suggested-position WeakSet tracking to notes.js, and renames the coverage cache-invalidation counter to editGen/bumpEditGen in state.js. main.js and dependent tests are updated accordingly, with a changelog entry documenting the migration. ChangesPainter extraction, state rename, and suggested-marks migration
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.44.1)src/main.jsast-grep timed out on this file Comment |
There was a problem hiding this comment.
Pull request overview
Refactors the editor render pipeline by extracting all canvas “painter” functions out of src/main.js into a dedicated src/draw.js module, while relocating related shared state/constants to the modules where they logically belong. This continues the ES-module migration by shrinking main.js and converting additional tests from slicer/eval-style to real imports.
Changes:
- Add
src/draw.jsand move canvas painting responsibilities (lanes/grid/notes/sections/beat bar/cursor/marquee + key-highlight state) frommain.jsinto it. - Move shared edit invalidation state to
src/state.js(editGen+bumpEditGen()), and move suggested-position mark tracking tosrc/notes.js(WeakSet + accessors); move note-body constants tosrc/geometry.js. - Update tests to import real functions and inject the real suggested-mark API where sandboxes previously extracted
@pure:suggest-marks.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/suggest_position_wiring.test.mjs | Injects real suggested-mark API from src/notes.js instead of extracting a pure block. |
| tests/suggest_position_persist.test.mjs | Migrates to ESM imports/URLs and injects real suggested-mark API from src/notes.js. |
| tests/suggest_position_move.test.mjs | Injects real suggested-mark API for the move sandbox; removes extracted suggest-marks block dependency. |
| tests/section_coverage.test.mjs | Imports _sectionCoveragePure directly from src/draw.js; updates shape assertions to read draw.js/state.js. |
| tests/key_highlight_hoist.test.js | Updates source-shape assertions to read src/draw.js after painter extraction. |
| tests/drum_limb_lint.test.js | Updates extracted-wrapper sandbox to use editGen instead of _coverageEditGen. |
| src/state.js | Introduces shared editGen and bumpEditGen() for cross-module memo invalidation. |
| src/notes.js | Adds suggested-position WeakSet + accessors (_markSuggested, _clearSuggested, _isSuggested, _suggestedNotes). |
| src/main.js | Removes inlined painter implementations; imports painters from draw.js; switches memo invalidation to bumpEditGen()/editGen; imports geometry/constants and suggested-mark API. |
| src/geometry.js | Moves MIN_NOTE_W / NOTE_PAD into shared geometry module. |
| src/draw.js | New module containing all extracted painters and _sectionCoveragePure, plus memoization keyed on shared editGen. |
| CHANGELOG.md | Documents step 9b refactor and related module/test moves. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/section_coverage.test.mjs (1)
158-159: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLoose ordering-dependent regex for the coverage-memo key check.
/editGen[\s\S]*?_covCache/only proveseditGenappears somewhere before_covCachein the file — it doesn't confirm the cache key construction indraw.jsactually useseditGen. A future refactor could satisfy this regex while breaking the real dependency (or vice versa, reordering could produce a false failure).♻️ Tighter shape check
- assert.ok(/editGen[\s\S]*?_covCache/.test(drawSrc), - 'the coverage memo must key on the edit generation counter'); + const covFnStart = drawSrc.indexOf('function _sectionCoverage'); + assert.ok(covFnStart >= 0, '_sectionCoverage must exist in draw.js'); + const covFnSlice = drawSrc.slice(covFnStart, covFnStart + 400); + assert.ok(/editGen/.test(covFnSlice) && /_covCache/.test(covFnSlice), + 'the coverage memo must key on the edit generation counter');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/section_coverage.test.mjs` around lines 158 - 159, The coverage-memo test in section_coverage.test.mjs uses a loose regex that only checks ordering, so tighten the assertion to verify the actual cache-key construction in draw.js rather than just matching editGen before _covCache. Update the check near the editGen/_covCache assertion to target the relevant symbol or expression in drawSrc more precisely, so the test fails only when the memo key no longer depends on editGen.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/section_coverage.test.mjs`:
- Around line 158-159: The coverage-memo test in section_coverage.test.mjs uses
a loose regex that only checks ordering, so tighten the assertion to verify the
actual cache-key construction in draw.js rather than just matching editGen
before _covCache. Update the check near the editGen/_covCache assertion to
target the relevant symbol or expression in drawSrc more precisely, so the test
fails only when the memo key no longer depends on editGen.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4acbb407-097b-4440-8003-549188ce1ab0
📒 Files selected for processing (12)
CHANGELOG.mdsrc/draw.jssrc/geometry.jssrc/main.jssrc/notes.jssrc/state.jstests/drum_limb_lint.test.jstests/key_highlight_hoist.test.jstests/section_coverage.test.mjstests/suggest_position_move.test.mjstests/suggest_position_persist.test.mjstests/suggest_position_wiring.test.mjs
Step 9b.
main.js19,963 → 19,340. And the harnesses earned their keep: they caught two real bugs whilenode --teststayed 86/86 green.What moved
src/draw.js(684 lines) — the lane and grid backgrounds, beat bar, section-coverage strip, the fretted and piano-roll note painters, the cursor and marquee, the song-key highlight state those painters consult, andcanvasH.No DI seam, as predicted.
drawNow(), the per-frame orchestrator, stays inmain.jsand calls intodraw.js— so the edge is one-way, and its ~14 calls into other sections (tempo map, drum editor, parts view, the lane overlays) never cross the boundary.drawWaveformstays with it. It paints the onset-strip and bookmark overlays behindtypeofguards, and those guards exist for sliced test sandboxes, not because the overlays are optional — moving it would have silently stopped drawing them.Three things ride along, each to where it actually belongs:
WeakSet→notes.js(it's note metadata, and_drawNotereads it)MIN_NOTE_W/NOTE_PAD→geometry.js(note-body geometry, shared by painters and hit-testing)_coverageEditGen→state.js, renamededitGen. It was never a drawing concern. Three memos key on it — the coverage strip, the chord-at-cursor readout and the drum-limb lint — because an in-place note-time move keeps the notes array's identity and length, so a cheap cache key can't see it. A counter can't be written across a module boundary, soEditHistory._afterEdit()calls the exportedbumpEditGen().Two real bugs, caught by the harnesses
node --testwas 86/86 green for both.1. A loud one.
main.jsstill usedMIN_NOTE_W/NOTE_PADin hit-testing after they moved, and I hadn't imported them:NOTE_PAD is not definedon every mousemove.verify_hittestandverify_resizewent red immediately.2. A silent one — and no harness would have caught it either. Two reads survived in
main.js:With the counter moved away and not imported,
typeofyields'undefined', so the chord-display and drum-lint memos would have keyed on a constant0and never invalidated on an edit. Stale readouts, no error, no failing test. I found it by scanningmain.jsfor free identifiers that are neither declared nor imported — which is the check this whole class of refactor actually needs, and which I'll now run every step.That scan is also what made me look at where the counter belongs, rather than just re-exporting it from
draw.js.Tests
Five more off the slicer path.
section_coverageimports the real pure helper —draw.jsloads under node, which is exactly what thetypeof windowguard onDPRbought in 9a. Its two source-shape cases now readdraw.jsandstate.jsrespectively.suggest_position_persistjoinsmove/wiringin injecting the realWeakSet+ accessors. Each env builds fresh note objects, so an identity-keyed module-sharedWeakSetcannot leak marks between cases.key_highlight_hoistkeeps its source-shape assertions and readssrc/draw.js.Verification
node --test86/86,pytest248/248. No unused import, no dead export, no dangling@puremarker, no cycle.Where R2 stands
main.js21,176 → 19,340 across ten merged PRs. Ten modules:state,snap,theory,lanes,geometry,keys,notes,chords,canvas,draw. 29 real-import suites vs 57 slicers, from 0/82.Next up: Hit-testing (44 lines) and Mouse interactions (1,046) now sit directly on
geometry+lanes+draw.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Refactor