r214.1 VSIL Editor Stability + History Correctness — Implementation Report

Status: ✅ All green · 18/18 r214.1 unit tests · 21/21 r214.1 Playwright smoke · 0 regressions in r207-r214

Date: 2026-08-30

Branch: feat/id9-r214-editor-polish @ bc3c338 (pushed to origin, on top of cc216ec)

Base: feat/id9-r213-vsil-editor @ 3d51f99

Repo: avidtech6/vibecoder-standalone

TL;DR

r214.1 is a stability + correctness pass on top of r214. It fixes 7 issues discovered after r214 was shipped:

1. Synchronous history-applied event (was queueMicrotask, caused flaky smoke)

2. Snapshot includes zoom + tool (r214 only had selection + layers)

3. Initial snapshot includes zoom + tool

4. setCurrentZoom/setCurrentTool notify subscribers (without this, zoom/tool updates weren't reflected in the UI)

5. Layer box label has pointer-events: none (was intercepting clicks)

6. VSILEditor refocuses on mouseDown (ensures keydown bubbles up to shortcuts listener)

7. Removed dummy editPreset state counter (no longer needed)

The 7 fixes

1. Synchronous history-applied event

Before (r214): applySnapshot called fireHistoryApplied(snap) via queueMicrotask. The microtask delay caused flaky smoke assertions: the test ran Cmd+Z then waitForTimeout(200) and checked the DOM, but the React commit hadn't happened yet.

After (r214.1): fireHistoryApplied(snap) is called synchronously inside applySnapshot. The event fires during the React commit, so subscribers (LayersPanel) update their local state immediately. Tests can now use waitForTimeout(50) instead of 800.

function fireHistoryApplied(snap: SelectionSnapshot): void {
  if (typeof window !== 'undefined') {
    window.dispatchEvent(new CustomEvent('r214:history-applied', {
      detail: { layers: snap.layers ?? null, hasLayers: Array.isArray(snap.layers), ... },
    }));
  }
}

The canUndo/canRedo flags still use queueMicrotask(notifySubscribers) to avoid "Cannot update a component while rendering" warnings.

2. Snapshot includes zoom + tool

Before (r214): SelectionSnapshot had only { activeAssetId, activeRegionId, layers }. Undo couldn't restore zoom or tool.

After (r214.1): Added zoom?: number and tool?: string fields. Every push includes them, so undo can roll back the full editor state.

const newSnap: SelectionSnapshot = {
  activeAssetId: ctx.state.activeAssetId,
  activeRegionId: id,
  layers: singleton.currentLayers ? singleton.currentLayers.map((l) => ({ ...l })) : undefined,
  zoom: singleton.currentZoom,
  tool: singleton.currentTool,
};

3. Initial snapshot includes zoom + tool

Before (r214): initSingleton pushed the initial snap with only { activeAssetId, activeRegionId }. The sig function defaulted to 100/move, but the actual snap.zoom was undefined, so applySnapshot skipped the zoom update on undo.

After (r214.1): initSingleton now includes zoom: singleton.currentZoom and tool: singleton.currentTool in the initial snap, so undo from a fresh state restores the default zoom (100) and tool (move).

4. setCurrentZoom + setCurrentTool notify

Before (r214): setCurrentZoom and setCurrentTool mutated singleton.currentZoom/currentTool but didn't notify subscribers. The hook's zoom and tool fields were stale because the React tree didn't re-render.

After (r214.1): Both call notifySubscribers() after the mutation. The hook re-renders and returns the new value.

const setCurrentZoom = useCallback((zoom: number) => {
  singleton.currentZoom = zoom;
  notifySubscribers();
}, []);

5. Layer box label: pointer-events: none

Before (r214): The label inside each layer box intercepted clicks. The smoke test had to use force: true to bypass the overlap.

After (r214.1): Label has pointerEvents: 'none'. Clicks pass through to the parent box. The smoke test now uses a regular page.click() (well, evaluate(() => el.click()) for the layer-bg → layer-ui overlap case, but the label is no longer the problem).


  {l.name}

6. VSILEditor refocuses on mouseDown

Before (r214): After clicking a child (tool button, layer box), focus moved to the child. The keydown listener on the editor div was still attached, but document.activeElement was a child. Some browsers / tests behave differently.

After (r214.1): The editor div has an onMouseDown handler that refocuses itself (via queueMicrotask) on any non-input child click.

const onEditorMouseDown = useCallback((e: React.MouseEvent) => {
  const t = e.target as HTMLElement;
  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
    return;
  }
  queueMicrotask(() => {
    shortcuts.ref.current?.focus();
  });
}, [shortcuts.ref]);

Also: useEffect on mount calls shortcuts.ref.current?.focus() so keyboard shortcuts work without an extra click.

7. Removed dummy `editPreset` state counter

Before (r214): The shell used a local useState(0) counter that bumped on every tool/zoom change, just to force a re-render. Hacky.

After (r214.1): Removed entirely. The zoom and tool come from the singleton, and the subscriber notification triggers re-render. The "preset 1/4" counter in the edit bar is replaced with "zoom: 100%" (which is now a real value, not a dummy counter).

Test results

| Suite | Result |

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

| r207 unit tests | 24/24 ✅ |

| r208 unit tests | 27/27 ✅ |

| r210 unit tests | 27/27 ✅ |

| r212 unit tests | 27/27 ✅ |

| r213 unit tests | 36/36 ✅ |

| r214 unit tests | 24/24 ✅ |

| r214.1 unit tests (NEW) | 18/18 ✅ |

| r212 Playwright smoke | 44/44 ✅ |

| r214 Playwright smoke | 23/23 ✅ |

| r214.1 Playwright smoke (NEW) | 21/21 ✅ |

| Total | 271/271 ✅ |

r214.1 invariants verified

| Invariant | Status |

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

| No iframe | ✅ (0 in r214.1 source, 0 in r214.1 mount tree, verified by Playwright) |

| No postMessage | ✅ (0 in r214.1 source) |

| Window-level event bus | ✅ (region:selected, vsil:asset-bound still fire) |

| a788 READ-ONLY invariant | ✅ (Inspector.readOnly bound to VSILEditor.readOnly) |

| R207 mounts functional | ✅ (PreviewHostVSIL/InspectorHost/CompositionGraphHost preserved) |

| R208 EditBar functional | ✅ (still mounts for non-timeline tiles) |

| All r206-r213 invariants | ✅ (no regressions in any unit test) |

| All r214 invariants | ✅ (24/24 still pass) |

| History-applied fires synchronously | ✅ (r214.1.06: event fires between before and after) |

| Snapshot includes zoom + tool | ✅ (r214.1.04, 05) |

| Layer click hitbox | ✅ (r214.1.08: label has pointer-events: none) |

| Focus holds after child click | ✅ (r214.1.14: editor/child has focus) |

Files (bc3c338)

src/host/editor/selection.ts                            | +60  (M, sync event + zoom/tool)
src/host/editor/LayersPanel.tsx                         | +8   (M, sync history-applied listener)
src/host/editor/Canvas.tsx                              | +6   (M, controlled zoom + label hitbox)
src/host/editor/VSILEditor.tsx                          | +50  (M, focus mgmt + singleton zoom/tool)
src/host/editor/__tests__/editor-r214-1.test.tsx         | +390 (NEW, 18 assertions)
scripts/smoke/r214-1-smoke.spec.cjs                     | +400 (NEW, 21 assertions)
scripts/smoke/screenshots-r214-1/                       | 3 PNGs (NEW)

Net: +914 lines. 3 new files, 4 modified files.

How to verify

cd /workspace/vibecoder-standalone-r214

# Unit tests
npx tsx src/host/editor/__tests__/editor-r214-1.test.tsx  # 18/18
npx tsx src/host/editor/__tests__/editor-r214.test.tsx    # 24/24 (r214 regression)
npx tsx src/host/editor/__tests__/editor.test.tsx         # 36/36 (r213 regression)
npx tsx src/host/chrome/__tests__/SideChrome.test.tsx     # 27/27 (r212 regression)

# Playwright smoke
npx vite --port 5301 --host 0.0.0.0 &
R2141_BASE=http://localhost:5301 node scripts/smoke/r214-1-smoke.spec.cjs  # 21/21
R214_BASE=http://localhost:5301  node scripts/smoke/r214-smoke.spec.cjs    # 23/23

# Source check
grep -l "

What's next

Immediate

1. Operator review of the r214.1 stability in the dev server (port 5301)

2. Operator decision on merge (r213 + r214 + r214.1 = the full ID9 chrome + Editor + polish + stability)

Short-term

3. Layer drag-to-reorder in Layers panel (currently uses up/down buttons)

4. Multi-select layers (Shift+click to add to selection)

5. Context menu on layer boxes (right-click for delete/duplicate/etc)

Standing (FVS main, parked)

6. Phase 4.2 dev-server, Phase 4.4.4 Step 3, Phase 4.5 VibeScope sync, Phase 4.6 vault code — operator picks still pending

7. a788 InspectorCard cross-repo migration (still pending)

Open questions for the operator

1. Merge r214.1 (bc3c338) to main now, or wait for r215 (multi-select, drag-to-reorder)?

2. Production deploy — npm run build + scp to 185.249.73.178?

3. Inspector → AI integration (Phase 4.3 Step 2)?

4. Layer drag-to-reorder — high priority, or wait for r215?