**Branch**: `feat/id9-r218-refinement` @ `51b2cd3`
**Base**: `main` @ `3fbedab` (post r217 merge)
**Worktree**: `/workspace/vibecoder-standalone-r218` (Vite port 5305)
**Tests**: 33/33 r218 unit + 19/19 r218 Playwright + 279/279 prior unit + 158/158 prior Playwright = **489/489 ✅**
**Build**: `npm run build` clean, 0 postMessage, 0 iframes (1 dynamic-import ref to `replace-iframe-vsil.js`, expected)
---
r218 is the **VSIL Editor refinement pass** that adds three integrated quality-of-life features to the multi-select editor built in r213–r217:
1. **Marquee drag-select** — click-and-drag on the canvas to rubber-band-select all layers whose bounds intersect the rectangle. Replaces the previous "click a single layer to select it" workflow for batch operations.
2. **`[` / `]` keyboard shortcuts** — move the currently-selected layer (or selection block) up or down in the layers panel. Bound to the editor's `useShortcuts` hook alongside the existing shortcuts.
3. **Multi-select block move (preserves internal order)** — when multiple layers are selected and the operator hits `[` or `]`, the entire block moves as a contiguous unit, preserving the internal order of the selection.
These three features work together: drag-select builds the multi-selection, then `[` / `]` repositions the whole block in the stack.
---
The layers panel's local React state must stay in sync with the canonical layers list maintained by the selection hook. The selection hook is the single source of truth (singleton `currentLayers`), but the panel's local state is what the user sees.
**The pattern**: a module-level `reorderListener: fn | null` slot. `setReorderListener(fn)` replaces whatever was there. `getReorderListener()` returns the current. When `reorderSelected` mutates the layers, it invokes the listener.
```ts
// selection.ts
let reorderListener: ((next: LayerSnapshot[]) => void) | null = null;
export function setReorderListener(fn: ((next: LayerSnapshot[]) => void) | null) {
reorderListener = fn;
}
export function getReorderListener() {
return reorderListener;
}
```
The LayersPanel composes with whatever was there before (so test listeners can still fire), and restores the previous listener on unmount:
```ts
// LayersPanel.tsx mount effect
useEffect(() => {
selection.pushSnapshot(layers.map(...));
selection.setCurrentLayers(layers);
const prevListener = getReorderListener();
setReorderListener((next) => {
if (prevListener) prevListener(next);
setLayers((prev) => { /* remap local state */ });
});
return () => { setReorderListener(prevListener); };
}, []);
```
This is the r218 single-slot model: only one production listener is active at a time, but composing with the previous listener allows the test runner (which installs its own listener per test) to keep working.
The naive approach (swap pairs) destroys internal multi-select order. r218 uses a block-move algorithm that preserves the order of the selection while repositioning the whole block.
```ts
const reorderSelected = useCallback((direction, mutate) => {
const selectedSet = new Set(singleton.currentSelected);
if (selectedSet.size === 0) return;
if (!singleton.currentLayers || singleton.currentLayers.length === 0) return;
const current = [...singleton.currentLayers];
const selected: typeof current = [];
const unselected: typeof current = [];
for (const l of current) {
if (selectedSet.has(l.id)) selected.push(l);
else unselected.push(l);
}
let next: typeof current;
if (direction === 'up') {
const firstSelectedIdx = current.findIndex((l) => selectedSet.has(l.id));
if (firstSelectedIdx === 0) return; // already at top
const insertAt = firstSelectedIdx - 1;
const filtered = current.filter((l) => !selectedSet.has(l.id));
next = [...filtered.slice(0, insertAt), ...selected, ...filtered.slice(insertAt)];
} else {
let lastSelectedIdx = -1;
for (let i = 0; i < current.length; i++) {
if (selectedSet.has(current[i].id)) lastSelectedIdx = i;
}
if (lastSelectedIdx === current.length - 1) return; // already at bottom
const insertAt = lastSelectedIdx + 1;
const filtered = current.filter((l) => !selectedSet.has(l.id));
next = [...filtered.slice(0, insertAt), ...selected, ...filtered.slice(insertAt)];
}
if (next.length === current.length && next.every((l, i) => l.id === current[i].id)) return;
mutate(next);
if (reorderListener) reorderListener(next);
singleton.currentLayers = next.map((l) => ({ ...l }));
pushSnap(buildSnap({ layers: next.map((l) => ({ ...l })) }));
}, [ctx]);
```
The algorithm:
1. Partition `current` into `selected` (preserving order) and `unselected`.
2. Find the first/last selected index in the original `current`.
3. Compute the insertAt position: one before the first selected (up) or one after the last selected (down).
4. Build the new list: `unselected[0..insertAt-1] + selected + unselected[insertAt..]`.
The "block" moves as a contiguous unit. Internal order is preserved. If the block is at the boundary (top or bottom), it's a no-op.
The previous r214 code had:
```ts
const layersRef = useRef<Layer[]>([]); // <-- NEVER WRITTEN. Read in onLayerUp/onLayerDown.
```
The r217 fix replaced this with `selection.layers` (sourced from the singleton). r218 takes the next step and uses the same `selection.layers` for `onLayerUp` / `onLayerDown`:
```ts
const onLayerUp = useCallback(() => {
selection.reorderSelected('up', () => {});
}, [selection]);
const onLayerDown = useCallback(() => {
selection.reorderSelected('down', () => {});
}, [selection]);
```
The `mutate` argument is a stub (the selection hook mutates the singleton directly; the listener is what reaches React state).
The marquee is drawn in viewport-relative CSS pixels and the layer box bounds are in scene coords (0..1920, 0..1080). The commit logic maps viewport → scene using the viewport's bounding rect.
```ts
// onMouseDown: clamp startX/Y to viewport
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const y = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
marqueeRef.current = { startX: x, startY: y, curX: x, curY: y, shift: e.shiftKey };
didDragRef.current = false;
setMarquee({ startX: x, startY: y, curX: x, curY: y });
// onMouseMove: track current X/Y
marqueeRef.current.curX = x;
marqueeRef.current.curY = y;
setMarquee({ ... });
// onMouseUp (or document-level mouseup): commit
const scaleX = 1920 / rect.width;
const scaleY = 1080 / rect.height;
const x1 = Math.min(m.startX, m.curX) * scaleX;
const y1 = Math.min(m.startY, m.curY) * scaleY;
const x2 = Math.max(m.startX, m.curX) * scaleX;
const y2 = Math.max(m.startY, m.curY) * scaleY;
const hits = [];
for (const l of layers) {
if (l.bounds.x < x2 && l.bounds.x + l.bounds.w > x1 &&
l.bounds.y < y2 && l.bounds.y + l.bounds.h > y1) {
hits.push(l.id);
}
}
if (hits.length > 0) selection.setSelectedByRect(hits, { toggle: m.shift });
```
**Critical fix (the one that took 4 hours to find)**: the canvas's `onMouseUp` doesn't always fire because the mouseup can land on a child element (a layer box). r218 adds a **document-level mouseup listener** that fires the commit when a marquee is active, regardless of where mouseup lands:
```ts
useEffect(() => {
if (typeof window === 'undefined') return;
const onDocMouseUp = () => {
if (marqueeRef.current) onMouseUp();
};
document.addEventListener('mouseup', onDocMouseUp);
return () => document.removeEventListener('mouseup', onDocMouseUp);
}, [onMouseUp]);
```
When the user clicks on a layer box, two things happen:
1. `mousedown` → canvas's onMouseDown starts a marquee at the click position.
2. `mouseup` → the layer box's `onClick` fires.
If the user dragged (mouse moved > 2px), the marquee wins. If the user clicked (no drag), the layer box's `onClick` wins (single-select).
```ts
// onClickLayer
if (didDragRef.current) {
didDragRef.current = false; // reset for next click
return; // marquee already committed; don't single-select
}
selection.setActiveRegionId(layerId as any);
```
---
| Case | Behavior |
|------|----------|
| Marquee with no movement (click without drag) | didDragRef stays false; onClickLayer fires (single-select wins) |
| Marquee starts outside viewport (canvas padding) | Clamped to [0, viewport.size] on onMouseDown |
| Marquee ends on a layer box (mouseup intercepted) | Document-level mouseup listener catches it; commit fires |
| Multi-select with `[` at top boundary | No-op (firstSelectedIdx === 0) |
| Multi-select with `]` at bottom boundary | No-op (lastSelectedIdx === length-1) |
| Selection of 1 layer via `[` or `]` | Works the same as multi-select (block-of-1) |
| Drag-select with Shift held | Toggles intersecting layers in/out of selection |
| Drag-select with plain click | Replaces selection with intersecting layers |
| Esc during drag-select | No effect (the marquee is committed on mouseup, not on Esc) |
| `reorderSelected` on empty selection | Early return |
| Marquee commits to 0 hits | No-op (selection unchanged) |
---
`src/host/editor/__tests__/editor-r218.test.tsx` — 25 sections:
1. selection hook exports `reorderSelected` and `setSelectedByRect`
2. selection hook exports `setReorderListener` and `getReorderListener`
3. `reorderSelected('up')` on single layer moves it up
4. `reorderSelected('down')` on single layer moves it down
5. `reorderSelected('up')` at top is a no-op
6. `reorderSelected('down')` at bottom is a no-op
7. `reorderSelected('up')` on multi-select moves block as a unit
8. `reorderSelected('down')` on multi-select moves block as a unit
9. multi-select block move preserves INTERNAL order
10. `reorderSelected` on empty selection is a no-op
11. `reorderSelected` invokes the registered listener
12. `reorderSelected` pushes a history snapshot (6 surfaces)
13. `setSelectedByRect` with no `toggle` replaces selection
14. `setSelectedByRect` with `toggle: true` toggles each hit
15. `setReorderListener` replaces prior listener (single-slot)
16. `setReorderListener(null)` clears the slot
17. LayersPanel mounts and registers a reorder listener
18. LayersPanel unmounts and restores the previous listener
19. LayersPanel local state stays in sync after reorderSelected
20. Canvas marquee state renders when marquee is non-null
21. Canvas marquee state is null after commit
22. Source-level: 0 iframes, 0 postMessage in r218 source
23. r217 selectAll after reorderSelected still selects all
24. r217 Esc after reorderSelected still clears selection
25. reorderSelected updates LayersPanel local state via listener
`scripts/smoke/r218-smoke.spec.cjs`:
1. Editor mounted with 4 default layers
2. `]` moves selected layer (fx) down
3. `]` again moves fx to next position
4. `]` at bottom is a no-op
5. `[` moves selected layer (bg) up
6. `[` moves layer-fx to top
7. `]` moves layer-fx to index 1
8. `]` again moves layer-fx to index 2
9. Multi-select reorder (shift+click 2 layers, then `[`)
10. Undo restores original order
11. Multi-transform apply (`r216`)
12. Canvas drag-select draws a marquee
13. Marquee select selects intersecting layers
14. Esc clears the drag-select
15. r217 Cmd+A still works
16. 0 iframes in DOM
(Plus 3 supporting sections for invariant verification.)
| Suite | Tests | Status |
|-------|-------|--------|
| r207 mounts | 24 | ✅ |
| r208 integration | 27 | ✅ |
| r210 minimal-header | 27 | ✅ |
| r212 side-chrome | 27 | ✅ |
| r213 editor (base) | 36 | ✅ |
| r214 editor polish | 24 | ✅ |
| r214.1 history-applied | 18 | ✅ |
| r215 multi-select + drag-reorder | 25 | ✅ |
| r216 multi-transform + context menu | 41 | ✅ |
| r217 multi-select keyboard shortcuts | 30 | ✅ |
| **r218 marquee + reorder** | **33** | **✅** |
| **Subtotal unit** | **312** | **✅** |
| Prior Playwright | 158 | ✅ |
| **r218 Playwright smoke** | **19** | **✅** |
| **TOTAL** | **489** | **✅** |
---
| File | Lines changed | Purpose |
|------|---------------|---------|
| `src/host/editor/selection.ts` | +141 | Added `reorderSelected`, `setSelectedByRect`, `setReorderListener`, `getReorderListener` |
| `src/host/editor/VSILEditor.tsx` | +20 / -12 | Wired `[` / `]` shortcuts to `reorderSelected`; removed dead `layersRef` |
| `src/host/editor/LayersPanel.tsx` | +21 | Registers reorder listener on mount, composes with prevListener |
| `src/host/editor/Canvas.tsx` | +167 / -30 | Marquee state, drag-ref-based click suppression, viewport-relative coords, document mouseup |
| `src/host/editor/__tests__/editor-r218.test.tsx` | +499 (new) | 33 unit tests across 25 sections |
| `scripts/smoke/r218-smoke.spec.cjs` | +322 (new) | 19 Playwright smoke tests |
| `scripts/smoke/screenshots-r218/*.png` | 4 (new) | Visual evidence |
---
---
1. **Marquee Esc during drag doesn't cancel** — Esc is bound to clearSelection, but during an in-progress drag the marquee is not yet committed, so Esc has no effect. A future iteration could add Esc-during-drag-cancel. Not in scope for r218.
2. **Marquee doesn't union with current selection unless Shift held** — by design: plain drag replaces. Shift+drag toggles. This matches the convention of Figma, Photoshop, and other tools.
3. **`reorderSelected` with non-contiguous multi-select** — the algorithm assumes the selected layers form a contiguous block. If they don't, the result still moves them as a contiguous block (re-grouped). This matches the user's mental model ("move my selection up").
4. **Document-level mouseup listener** — could leak if Canvas unmounts mid-drag. The cleanup function in useEffect handles normal unmount, but a force-unmount during a drag leaves the listener in place until next render. Acceptable for now; r219 could add a guard.
---
1. **Review the r218 branch** on `feat/id9-r218-refinement` @ `51b2cd3` (already pushed to origin).
2. **Run locally** to verify: `cd /workspace/vibecoder-standalone-r218 && npm install && npm run dev` (port 5305) — open `http://localhost:5305`, navigate to a scene, try the marquee + `[` / `]` keys.
3. **Approve and merge to main** when ready. r218 follows the r217 merge pattern: `git checkout main && git merge --no-ff feat/id9-r218-refinement -m "merge: r218 → main (...)" && git tag -a r218-shipped -m "..." && git push origin main r218-shipped`.
4. **Production deploy is still BLOCKED** — needs SSH access to `operator@185.249.73.178` to run `deploy.sh`. See bulletin b1866.
---
*Generated 2026-08-30 by VibeCoder-standalone Mavis (thread 9).*