ID9 r220 — VSIL Editor Refinement (Marquee Pure Module, Ref-Based Listeners, Snapshot Completeness)

**Date**: 2026-08-31

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

**Status**: SHIPPED to feature branch; awaiting merge to main

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

Summary

r220 is the final editor refinement pass for the VibeCoder VSIL editor. It extracts the marquee geometry algorithm to a pure utility module, refactors the Canvas to use ref-based document listeners (no more re-registration on every callback change), adds a visual snap indicator, memoizes the outline renderers, and audits the selection snapshot to include all 6+ layer fields (id, visible, locked, opacity, blendMode, name) plus a monotonic seq counter.

The marquee module (`marquee.ts`) is the canonical example of the r220 pattern: **algorithm in a pure module, presentation in a React component**. It can be tested in isolation without React/DOM, and the Canvas becomes a thin wrapper that calls the pure functions and renders the result.

What changed

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

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

The snap algorithm collects all candidate edges (start, current, and all 4 edges of every layer), then for each marquee edge finds the closest edge within `snapPx`. If found, snap to it. This is the r220 pattern: pure function, deterministic, testable.

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

Key changes from r219:

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

4. Tests

**`src/host/editor/__tests__/editor-r220.test.tsx`** (NEW, 29386 bytes, 74 sections, 74/74 passing):

Sections cover:

**`scripts/smoke/r220-smoke.spec.cjs`** (NEW, 12441 bytes, 28/28 passing):

Sections cover:

5. Existing test updates

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 | 74/74 ✅ |

| **Unit total** | **355/355** |

| r218 Playwright | 19/19 (prior) |

| r219 Playwright | 29/29 ✅ (re-verified) |

| r220 Playwright | 28/28 ✅ |

| **Playwright total** | **76/76** |

| **Grand total** | **431/431** |

| 0 iframes in built bundle | ✅ (1 dynamic import reference to `replace-iframe-vsil` chunk — not a DOM iframe) |

| 0 postMessage in source | ✅ |

Architecture decisions

Why extract marquee to a pure module?

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

The Canvas becomes a thin wrapper that:

1. Tracks pointer state (ref-based)

2. Calls `computeMarquee(...)` with the current state

3. Renders the result (marquee div + snap indicators)

Why ref-based document listeners?

The r219 approach registered document listeners in a `useEffect` with `[onMouseUp]` as dep. When `onMouseUp` changed (which it did on every state change), the listeners were removed and re-registered. This was wasteful and could cause subtle bugs.

r220 registers the listeners ONCE on mount and delegates to refs. The listeners always see the latest `marqueeRef.current` / `didDragRef.current` / `viewportRef.current` without re-binding. No leak risk, no stale closures.

Why a document-level mousemove?

r219 only listened for mouseup at document level. If the user dragged off the canvas (e.g. into the LayersPanel), the canvas's `onMouseMove` stopped firing but the marquee state froze. r220 adds `onDocMouseMove` that updates the marquee state even when the pointer is outside the viewport. Lost drag recovery.

Why memoize the outline renderers?

The outline renderers (hover, multi-select, snap indicator) are simple divs, but they re-render on every parent re-render. The Canvas re-renders on every state change (zoom, pan, marquee). Without memoization, the outlines re-render too. With `useMemo`, they only re-render when their specific deps change. This is a perf win for large scenes.

Why audit the snapshot signature?

r216 added `opacity`, `blendMode`, `name` to layers. But the r218 snapshot signature only included `id`, `visible`, `locked` + `name`. r220 extends `LayerSnapshotData` to include all 6 fields and updates `sig()` to match. This ensures undo/redo correctly detects changes to opacity/blendMode. Without this, undoing a opacity change would be a no-op (same sig → deduped).

The `seq` counter is a debug aid. It increments on every `pushSnap` and is included in the snapshot. Tests can verify the order of operations.

Files changed

```

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

scripts/smoke/r220-smoke.spec.cjs | 322 ++++++++ (NEW)

scripts/smoke/screenshots-r220/*.png | 4 files (NEW)

src/host/editor/Canvas.tsx | refactored

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 | 824 +++++++++++ (NEW)

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

src/host/editor/selection.ts | extended

```

Next steps

1. Merge `feat/id9-r220-refinement` → `main` with `--no-ff`

2. Tag `r220-shipped`

3. Operator deploys to prod (id 44)

4. Lock r220 pattern in agent memory (12-point reflex)

5. Post bulletin b1879 "r220 SHIPPED"

6. Upload artifacts to HQ (impl report = a845, AI summary = a846, screenshots = a847-a850)

Locked-in invariants for r221+