ID9 R217 — VSIL Editor Multi-Select Keyboard Shortcuts (Cmd+A + Esc)

Date: 2026-08-30
Branch: feat/id9-r217-keyboard
Base: main @ cc5a51d (post-r216 merge)
Worktree: /workspace/vibecoder-standalone-r214
Author: vibecoder-standalone-mavis (thread 9)
Status: SHIPPED LOCALLY PENDING PUSH PROD DEPLOY BLOCKED

What

R217 adds two keyboard shortcuts to the VSIL Editor's multi-select model:

KeyActionWhen
Cmd/Ctrl + ASelect all layersEditor focused, not in input
EscapeClear selectionEditor focused, not in input

Both shortcuts:

R217 also fixes a latent bug in r214 where onLayerUp/onLayerDown and the new onSelectAll read from layersRef.current, which was never updated. The fix: expose selection.layers from the hook (sourced from the singleton's currentLayers, which LayersPanel sets via setCurrentLayers on every render).

Why

After r215 + r216, the editor has rich multi-select + multi-transform capability, but only mouse-driven. The user (operator) needs keyboard parity:

Both also exercise the snapshot pipeline (multi-select → snap → undoable), so they double as smoke tests for the r215/r216 history correctness.

Files Touched

Modified (3 files)

src/host/editor/shortcuts.ts (+38 lines, net +30):

src/host/editor/selection.ts (+2 lines):

src/host/editor/VSILEditor.tsx (+2 lines, +4 net):

New (2 files)

src/host/editor/__tests__/editor-r217.test.tsx (32537 bytes):

scripts/smoke/r217-smoke.spec.cjs (8687 bytes):

Key Patterns

1. Shortcut handler extension

The useShortcuts hook is data-driven: each consumer (VSILEditor) passes handler functions. R217 extends the interface with two new optional handlers:

export interface ShortcutHandlers {
  // ... existing handlers ...
  onSelectAll?: () => void;
  onClearSelection?: () => void;
}

2. Selection hook extension

The useEditorSelection hook now exposes layers (sourced from the singleton's currentLayers, which LayersPanel writes via setCurrentLayers on every render). This is the source of truth that keyboard shortcuts can read without depending on DOM state.

const selectAll = useCallback((allLayerIds: RegionId[]) => {
  if (allLayerIds.length === 0) return;
  if (singleton.currentSelected.length === allLayerIds.length &&
      singleton.currentSelected.every((id, i) => id === allLayerIds[i])) {
    return; // already in this state — no-op
  }
  singleton.currentSelected = [...allLayerIds];
  const first = allLayerIds[0] ?? null;
  ctx.state.activeRegionId = first;
  pushSnap(buildSnap({ activeRegionId: first, selectedRegionIds: [...allLayerIds] }));
}, [ctx]);

Both selectAll and clearSelection push snapshots with all 6 surfaces.

3. The layersRef bug (and the fix)

In r214, VSILEditor had:

const layersRef = useRef<LayerEntry[]>([]);
// ... never assigned anywhere ...
onSelectAll: () => {
  const arr = layersRef.current; // always []
  selectionRef.current.selectAll(arr.map((l) => l.id));
},

The ref was read but never written. The fix replaces it with selection.layers:

onSelectAll: () => {
  const arr = selection.layers;
  selectionRef.current.selectAll(arr.map((l: any) => l.id));
},

This also unblocks onLayerUp/onLayerDown (the r214 layer reorder shortcuts) which had the same bug — but those are out of scope for r217.

4. Input guard (skip when typing)

if (target && (
  target.tagName === 'INPUT' ||
  target.tagName === 'TEXTAREA' ||
  target.tagName === 'SELECT' ||
  target.isContentEditable
)) {
  return; // let the input handle its own Escape (e.g. blur)
}

Test Results

Unit tests (30/30 pass)

#TestResult
1selectAll sets selectedRegionIds to all layer ids✓
2selectAll pushes a snapshot with all 6 surfaces✓
3selectAll is no-op when set is already the same✓
4clearSelection empties selectedRegionIds + active✓
5clearSelection pushes a snapshot with empty set✓
6clearSelection is no-op when already empty✓
7Undo restores selection after selectAll✓
8Redo re-applies selection after selectAll + undo✓
9Cmd+A keypress triggers onSelectAll✓
10Esc keypress triggers onClearSelection✓
11Cmd+A is ignored when typing in an input✓
12Esc is ignored when typing in an input✓
13selectAll + Inspector reflects multi-select✓
14clearSelection + Inspector reflects no selection✓
15Canvas highlights all selected after selectAll✓
16Canvas clears highlight after clearSelection✓
17Label has pointer-events: none (r214.1 invariant)✓
18Source-level: 0 iframes, 0 postMessage in source✓
19Ctrl+A works (non-Mac modifier)✓
20selectAll on empty layer list is no-op✓

Playwright smoke (14/14 pass)

#TestResult
1Page loads + Editor mounts via SideChrome → Edit✓
24 default layers visible✓
3Click layer-bg to select it (1 selected)✓
4Cmd+A → all 4 layers selected (footer: "4 selected")✓
5Inspector shows "4 SELECTED" tag✓
6Esc → selection cleared (0 selected, no multi-tag)✓
7Cmd+A again → all 4 selected✓
8Esc → cleared✓
9Ctrl+A also works (non-Mac)✓
10Undo after Esc restores the previous selection✓
11Cmd+A on row-bg then Esc, then Cmd+A again✓
120 iframes in DOM✓

Regression sweep

SuiteUnitPlaywright
r20724(n/a)
r20827(n/a)
r21027(n/a)
r2122744 (deferred)
r2133631
r2142423
r214.11821
r2152518
r2164121
r2173014
Total (this worktree)279128

Build

dist/assets/main-BYn0C_p6.js   804.32 kB │ gzip: 145.19 kB
dist/assets/main-E2xBBPNt.css   59.03 kB │ gzip:  10.04 kB

0 iframes, 0 postMessage in built bundle.

Invariants Preserved

Out of Scope (Documented Bugs for r218)

Git State

feat/id9-r217-keyboard ← r217 SHIPPED LOCALLY (this commit)
  └─ [next commit pending — impl report + AI summary + commit + push]
main ← cc5a51d (r216 merged)

r217 is ready to push to origin/feat/id9-r217-keyboard. Merge to main is awaiting operator's call.

Production Deploy Status

BLOCKED — VPS SSH to operator@185.249.73.178 fails:

Build is ready. Operator needs to either:

  1. Provide SSH key for operator@185.249.73.178, OR
  2. Run deploy.sh from operator's laptop (script is in scripts/deploy.sh on the VPS)

Bulletin b1866 already posted with this blocker.

Next Steps

  1. Push r217 to feat/id9-r217-keyboard
  2. Bulletin b1867 — r217 SHIPPED
  3. Lock r217 pattern in memory (10-point reflex)
  4. Operator directive: SSH key, merge approval, or feedback