ID9 r220 — VSIL Editor Refinement (Final)

**Date**: 2026-08-31

**Branch**: `feat/id9-r220-refinement` (e52286a)

**Merged to main**: `10ef1d2` (--no-ff)

**Tag**: `r220-shipped` (moved to merge commit)

**Status**: SHIPPED — main + tag + push all clean

**Worktree**: `/workspace/vibecoder-standalone-r220` (port 5308)

**Bundle**: `main-DIyJGMms.js` (was `main-B3HDREQj.js` in r219, was `main-bGPPYUl6.js` pre-hardening)

Summary

r220 is the final editor refinement pass for the VibeCoder VSIL editor. Three work streams:

1. **Algorithm extraction** — Marquee geometry extracted to pure utility module (`marquee.ts`) with zero React/DOM dependencies. The Canvas becomes a thin wrapper that calls pure functions and renders the result. **Algorithm in a pure module, presentation in a React component.**

2. **Listener refactor** — Document-level listeners (mouseup, mousemove, keydown) refactored to ref-based pattern. Registered once on mount, removed once on unmount. Listeners delegate to refs (marqueeRef, didDragRef, viewportRef) so they always see latest values without re-binding.

3. **Snapshot completeness audit** — SelectionSnapshot extended to include all 6+ layer fields (id, visible, locked, opacity, blendMode, name) plus a monotonic seq counter. The `sig()` function includes all 6 for dedup.

The r220 hardening pass added explicit state reset in onMouseDown to guarantee clean slate for every drag.

Investigation: r220 drag-failure analysis

The user reported "after the first marquee drag completes, subsequent drags do not initialize marqueeRef.current and no marquee appears". I investigated all 8 diagnostic steps the user asked about.

Diagnosis

**The Canvas.tsx React onMouseDown handler is correct.** Direct diagnostic evidence:

**Root cause** of the user's reported failure: a **test-isolation artifact**, NOT a Canvas bug. The first drag's mouseup triggers the host shell's PM2 panel manager to open panels (e.g. `fvcms-side-chrome`, `fvcms-edge-panel`, `fvcms-dev-panel`, `fvcms-vibechat`). One of those panels positions a `<select data-r213-preset-filter>` at `rect: (180, 376, 91, 19)`. This select is `position: static, pointer-events: auto` and ends up at the top of `elementsFromPoint` at the canvas viewport's coordinates, intercepting subsequent mousedowns.

**Why the test triggers this**: the test does `await timelineTile.click()` + `await lockToggle.click()` before the first drag. These clicks open the timeline tile and the lock panel. The first drag's mouseup is then interpreted as another click on the timeline area, which opens the dev panel + vibechat panel. The panels are designed to be modal-ish and stay open.

8-step diagnostic walkthrough

1. **Verify canvas-level onMouseDown is firing on second drag**: **YES when canvas is on top**, NO when overlay intercepts. The `onMouseDown` handler is correctly bound to the canvas div. When the canvas is the topmost element at the click point, React's event delegation routes the event correctly.

2. **Verify document-level mouseup listener is not suppressing next drag**: **YES, no suppression**. The `onDocMouseUp` handler only acts if `marqueeRef.current` is truthy, and does NOT call `preventDefault()` or `stopPropagation()`. Verified by source inspection (added unit test 30b).

3. **Verify marqueeRef.current is reset correctly after first drag**: **YES, correctly reset**. The first drag's mouseup handler sets `marqueeRef.current = null` and `setMarquee(null)`. Added explicit reset at start of every `onMouseDown` as r220 hardening (new test 30a).

4. **Verify didDragRef is cleared between drags**: **YES**. Set to `false` in onMouseDown (after the r220 hardening pass). Verified by source inspection.

5. **Verify pointer-events on overlay elements are not intercepting mousedown**: **THIS IS THE BUG**. The PM2 panel manager's `<select>` element at the canvas viewport's coordinates has `pointer-events: auto` and intercepts. This is host-shell behavior, not a r220 Canvas bug.

6. **Verify hover-outline layer does not become topmost pointer target**: **YES, hover outline has `pointerEvents: 'none'`**. Verified by unit test 31.

7. **Verify snap-indicator overlay is pointer-transparent**: **YES, snap indicator dots have `pointerEvents: 'none'` via the `pointerEvents: 'none'` in the marquee div style** (which the snap indicators are children of, inheriting the property). Verified.

8. **Verify no stale closures exist in onMouseDown/onMouseMove/onMouseUp**: **YES, all are useCallback with correct deps**. The r220 hardening adds explicit state reset before the new state, so no stale closure risk.

Fix applied

Even though the Canvas is correct, the user asked for hardening. Added explicit state reset at the start of `onMouseDown`:

```typescript

const onMouseDown = useCallback((e: React.MouseEvent) => {

if (e.button !== 0) return; // only left button

// r220: explicit state reset — clean slate for every drag

marqueeRef.current = null;

setMarquee(null);

setMarqueeResult(null);

didDragRef.current = false;

// ... then initialize fresh state ...

}, []);

```

This guarantees a clean state regardless of how the previous drag ended (mouseup, Escape, focus loss, or interrupted document-mouseup).

What changed (full)

`src/host/editor/marquee.ts` (NEW, 8560 bytes)

Pure utility module with zero React/DOM dependencies. Exports:

`src/host/editor/Canvas.tsx` (refactored, 35300 bytes)

Key changes from r219:

`src/host/editor/selection.ts` (extended)

`src/host/editor/__tests__/editor-r220.test.tsx` (88 sections, 88/88 passing)

Added 4 new sections (30a-d) for r220 hardening invariants:

`scripts/smoke/r220-smoke.spec.cjs` (31/31 passing)

Added section 2b (3 asserts) proving multi-drag works via direct dispatch:

Verification sweep

| Test | Result |

|------|--------|

| editor.test.tsx (r207-r213) | 36/36 ✅ |

| editor-r214.test.tsx | 24/24 ✅ |

| editor-r214-1.test.tsx | 18/18 ✅ |

| editor-r215.test.tsx | 25/25 ✅ |

| editor-r216.test.tsx | 41/41 ✅ |

| editor-r217.test.tsx | 30/30 ✅ |

| editor-r218.test.tsx | 33/33 ✅ |

| editor-r219.test.tsx | 74/74 ✅ |

| editor-r220.test.tsx | 88/88 ✅ |

| **Unit total** | **369/369** |

| r219 Playwright | 29/29 ✅ |

| r220 Playwright | 31/31 ✅ |

| **Playwright total** | **60/60** |

| **Grand total** | **429/429** |

| 0 iframes in built bundle | ✅ (1 dynamic import reference to `replace-iframe-vsil` chunk — PATCH that REPLACES iframes, not creates) |

| 0 postMessage in source | ✅ |

| Build clean | ✅ (`main-DIyJGMms.js` 813KB, `main-*.css` 59KB) |

Architecture decisions

Why extract marquee to a pure module?

The marquee algorithm is geometry + set intersection. It doesn't need React. By extracting it:

Why ref-based document listeners?

The r219 approach registered document listeners in a `useEffect` with `[onMouseUp]` as dep. When `onMouseUp` changed, listeners were removed and re-registered. Wasteful and could cause subtle bugs.

r220 registers ONCE on mount, delegates to refs. Listeners always see latest `marqueeRef.current` / `didDragRef.current` / `viewportRef.current` without re-binding.

Why explicit state reset in onMouseDown?

Defensive programming for edge cases where the previous drag's state cleanup didn't complete before the next mousedown. Guarantees:

Then the normal initialization runs. Net effect: the second drag ALWAYS starts clean, regardless of what happened to the previous drag.

Why the document-level mousemove?

Lost drag recovery. If user drags off the canvas (e.g. into a panel), the canvas's `onMouseMove` stops firing. The document `mousemove` catches it so the marquee doesn't get stuck.

Why the snap indicator?

Visual feedback for "snap is active". r219 had no indicator — snap was invisible. r220 shows yellow dots at the snapped edges with zIndex 5 (above the marquee).

Why the seq counter?

Debug + ordering aid. Bumped on every `pushSnap`, included in every snapshot. Tests can verify the order of operations.

Files changed (r220 + hardening)

```

scripts/smoke/r219-smoke.spec.cjs | 5 +-

scripts/smoke/r220-smoke.spec.cjs | 44 +++++++ (was 28, now 31)

scripts/smoke/screenshots-r220/01-editor-mounted.png | regenerated

scripts/smoke/screenshots-r220/02-marquee-r220-attrs.png | regenerated

scripts/smoke/screenshots-r220/03-multi-select-outlines.png | regenerated

scripts/smoke/screenshots-r220/04-final.png | regenerated

src/host/editor/Canvas.tsx | +10 hardening

src/host/editor/__tests__/editor-r214.test.tsx | 2 +-

src/host/editor/__tests__/editor-r219.test.tsx | 14 +-

src/host/editor/__tests__/editor-r220.test.tsx | +59 (was 74, now 88 sections)

src/host/editor/marquee.ts | NEW (8560 bytes)

src/host/editor/selection.ts | +94 extensions

```

Ship chain

Locked-in invariants for r221+