# 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:
- **`clampToViewport(x, y, w, h)`** — clamps coordinates to viewport bounds using `Math.max(0, Math.min(coord, dim))` on both axes.
- **`computeScale(viewportRect)`** — computes scale factors from viewport dimensions (vs. 1920×1080 scene).
- **`viewportToScene(vx, vy, scale, vw, vh)`** — converts viewport coords to scene coords (0..1920, 0..1080).
- **`collectLayerEdges(layers, scale)`** — collects all layer edges in viewport coords for snap.
- **`computeMarquee(startX, startY, curX, curY, options)`** — the main function. Returns `{ rect, hits, isDegenerate, snappedEdges }`. Strict snap (distance must be < snapPx, not ≤). Supports NaN/Infinity handling.
- **`MarqueeOptions`, `MarqueeResult`, `MarqueeHit`, `LayerBounds`** interfaces.
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:
- **Marquee geometry delegated to marquee.ts** — the Canvas no longer computes the rect, hits, or snap inline. It calls `computeMarquee(...)` and gets back the result.
- **Ref-based document listeners** — single `useEffect` registers `mouseup`, `mousemove`, `keydown` on the document. The listeners delegate to refs (`marqueeRef`, `didDragRef`, `viewportRef`) so they always see the latest values. No more re-registration when `onMouseUp` changes.
- **Document-level mousemove fallback** — if a marquee is active and the pointer leaves the viewport, the document `mousemove` catches it so the marquee doesn't get stuck at the viewport edge.
- **Memoized outline renderers** — `useMemo` for layer box list, hover outline, multi-select outlines, and snap indicators. Each has its own dep array. Multi-select outlines only re-render when `selectedRegionIds` changes. Hover outline only when `hoveredLayerId` changes.
- **Configurable snap** — `Canvas.snapPx` prop (default 3, was hardcoded 4 in r219). `Canvas.snapDisabled` prop disables snap entirely. The marquee module uses strict `<` comparison.
- **New data attributes** — `data-r220-snap-active="1|0"`, `data-r220-hit-count="N"`, `data-r220-snap-indicator="startX|startY|curX|curY"`.
- **Snap indicator** — yellow `#ffeb3b` dots at snapped edges (4px diameter, zIndex 5). Renders ONLY during active drag AND only for edges that snapped.
### 3. `src/host/editor/selection.ts` (extended)
- **`LayerSnapshotData` interface** — `{ id, visible, locked, opacity?, blendMode?, name? }`. All 6 fields included; the last 3 are optional for backwards compat.
- **`SelectionSnapshot.seq?: number`** — monotonic counter bumped in `buildSnap`. Used for debug + ordering.
- **`sig()`** — includes all 6 fields for dedup.
- **`setCurrentLayers`** — now calls `notifySubscribers()` so the hook re-renders with the new layers.
- **`__getSelectionSingletonForTest`** — test helper to read the LIVE singleton state. The hook's returned `layers`/`selectedRegionIds` may be stale if the test doesn't wait for re-render.
### 4. Tests
**`src/host/editor/__tests__/editor-r220.test.tsx`** (NEW, 29386 bytes, 74 sections, 74/74 passing):
Sections cover:
- Marquee module: `clampToViewport`, `computeScale`, `viewportToScene`, `computeMarquee` (basic, snap, no-snap, degenerate, NaN/Infinity)
- Canvas: ref-based document listeners registered, `data-r218-marquee` rendered, `data-r220-snap-active` + `data-r220-hit-count` present, snap indicator rendered, hover/multi-select/marquee all `pointer-events: none`
- Selection: `LayerSnapshotData` includes all 6 fields, `seq` counter present, undo/redo of full-surface layers, `batchRename` pattern propagation, `applyTransformToSelected` preserves all fields
- 0 iframes, 0 postMessage in source files (excluding comments)
**`scripts/smoke/r220-smoke.spec.cjs`** (NEW, 12441 bytes, 28/28 passing):
Sections cover:
- 4 default layers mounted
- r220 marquee data attributes (`data-r220-snap-active`, `data-r220-hit-count`, `data-r220-snap-indicator`)
- Pixel-accurate intersection (hit-count reflects geometry)
- Snap-to-edges (3px threshold)
- Shift+drag additive mode (r219 invariant)
- Document-level mousemove fallback (source check)
- Esc-to-cancel mid-drag
- Hover + multi-select outlines (memoized renderers)
- Marquee commit via `setSelectedByRect`
- Snapshot completeness (opacity, blendMode, name, seq in source)
- 0 iframes in DOM, 0 postMessage in source
### 5. Existing test updates
- **`editor-r214.test.tsx`** — updated file count from 10 to 11 (added `marquee.ts`).
- **`editor-r219.test.tsx`** — updated `SNAP_PX` check to reflect r220's configurable `Canvas.snapPx` (default 3) and marquee.ts extraction.
- **`scripts/smoke/r219-smoke.spec.cjs`** — updated marquee startX clamp check to accept `clampToViewport` from marquee.ts.
## 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:
- **Testable in isolation** — no jsdom, no React render, no test wrappers
- **Reusable** — the same module can power marquee, lasso, crop, etc.
- **Deterministic** — pure function, same input → same output
- **Fast** — no React overhead, no virtual DOM
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+
- Pure utility module for marquee geometry (no React/DOM deps)
- Ref-based document listeners (single useEffect, single registration)
- Document-level mousemove fallback (lost drag recovery)
- Visual snap indicator (yellow dots at snapped edges)
- Memoized outline renderers (each with own dep array)
- Snapshot includes all 6+ layer fields (id, visible, locked, opacity, blendMode, name)
- Monotonic seq counter for debug + ordering
- Configurable snap (Canvas.snapPx prop, default 3, strict `<`)
- `__getSelectionSingletonForTest` for live state reads in tests
- `setCurrentLayers` calls `notifySubscribers` so the hook re-renders
- Build clean, 0 iframes, 0 postMessage