ID9 r221 — VSIL Editor Timeline Integration (Final)

**Date**: 2026-08-31

**Branch**: `feat/id9-r221-timeline-integration` (a47f4f5 / f9bbfce)

**Merged to main**: `6462d6a` (--no-ff)

**Tag**: `r221-shipped`

**Status**: SHIPPED to main + build clean + ready for prod deploy

**Worktree**: `/workspace/vibecoder-standalone-r221` (port 5310)

**Bundle**: `main-Cjt4-moM.js` (820.16 kB) + CSS (59.03 kB)

**Bundle hash change**: r220 `main-DIyJGMms.js` → r221 `main-Cjt4-moM.js`

Summary

r221 introduces a new `TimelineR221` component that renders **one row per layer** (in addition to the existing keyframe Timeline), with full bidirectional sync to the Canvas. The new Timeline lives in the editor-timeline panel below the keyframe Timeline, so both are visible without overlap.

The component uses the r220 selection singleton (with two new listener channels) to keep all four VSIL Editor surfaces — Canvas, LayersPanel, TimelineR221, Inspector — in lockstep. No new state store; no new events; no iframes; no postMessage.

Five requirements, each verified

1) Timeline Row Rendering ✅

2) Selection Sync ✅

3) Hover Sync ✅

4) Reorder Sync ✅

5) Snapshot Integration ✅

Architecture decisions

Why a new component (not extending the keyframe Timeline)?

The existing Timeline (r214) is for keyframes, states, and transitions — a different domain (time-based animation data, not layer metadata). Adding rows for layer data to the keyframe Timeline would conflate the two and break the r214 snapshot semantics. The new TimelineR221 lives alongside, focused entirely on layer state.

Why the chained listener pattern?

The selection singleton is the canonical source of truth. Multiple surfaces need to react to hover/selection/reorder changes. A single-slot chained listener (like r218's reorderListener) lets each surface register its own handler without coupling to the others. The chain composes:

Each surface can be added/removed without affecting the others.

Why fire `fireHoveredLayer` from Canvas AND from TimelineR221?

The chain listener is symmetric — whichever surface fires the event, all subscribers update. Canvas fires from `onLayerEnter` (mousing over a layer box). TimelineR221 fires from `onRowEnter` (mousing over a row). Both routes converge on the chain and update everyone.

Why `data-r221-*` attribute naming?

The `r2XX-*` convention preserves a clean audit trail. Every test/data attribute is namespaced to its r-series, so it's clear which ship introduced which surface.

Why push visibility/lock toggles as snapshots?

Operators expect visibility and lock changes to be undoable. Pushing a snapshot on every toggle gives them a clean undo/redo chain. The r220 `LayerSnapshotData` shape includes `visible` and `locked`, so undo restores them correctly.

Files changed (r221)

| File | Change | Lines |

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

| `src/host/editor/TimelineR221.tsx` | NEW | 530 |

| `src/host/editor/VSILEditor.tsx` | Mount TimelineR221 below keyframe Timeline | +5 |

| `src/host/editor/Canvas.tsx` | Call fireHoveredLayer from onLayerEnter + chain hover listener in mount effect | +28 |

| `src/host/editor/selection.ts` | Add hoveredLayerListener + fireHoveredLayer exports | +34 |

| `src/host/editor/__tests__/editor-r221.test.tsx` | NEW: 44 unit tests (8 sections) | 530 |

| `src/host/editor/__tests__/editor-r214.test.tsx` | Update component file count: 11 → 12 | +1 |

| `src/host/editor/__tests__/editor.test.tsx` | Update component file count: 8 → 9 | +1 |

| `scripts/smoke/r221-smoke.spec.cjs` | NEW: 22 Playwright tests (15 sections) | 450 |

| `scripts/smoke/screenshots-r221/*.png` | NEW: 2 screenshots | — |

Verification sweep

| Test | Result |

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

| editor.test.tsx (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 | 88/88 ✅ |

| editor-r221.test.tsx | **44/44** ✅ (NEW) |

| **Unit total** | **413/413** |

| r220 Playwright | 31/31 ✅ |

| r221 Playwright | **22/22** ✅ (NEW) |

| **Playwright total** | **53/53** |

| **Grand total** | **466/466** |

| 0 iframes in source (excluding comments) | ✅ |

| 0 postMessage in source (excluding comments) | ✅ |

| 0 iframes in built bundle | ✅ |

| 0 postMessage in built bundle | ✅ |

| Build clean | ✅ (`main-Cjt4-moM.js` 820 kB, CSS 59 kB) |

New API surface

`selection.ts` exports

```ts

// r221: hover listener pattern (single-slot, chained)

let hoveredLayerListener: ((id: string | null) => void) | null = null;

export function setHoveredLayerListener(listener): void;

export function getHoveredLayerListener(): typeof hoveredLayerListener;

export function fireHoveredLayer(id: string | null): boolean;

```

`TimelineR221.tsx` props

```ts

export interface TimelineR221Props {

initialLayers?: TimelineR221Layer[]; // default: 4 demo layers

onSelectionChange?: (selectedIds: RegionId[]) => void;

onHoverChange?: (hoveredId: string | null) => void;

}

```

`TimelineR221Layer` shape

```ts

export interface TimelineR221Layer {

id: string;

name: string;

visible: boolean;

locked: boolean;

color?: string; // optional track color

}

```

r221 data attributes (for test introspection)

Ship chain

Locked-in invariants for r222+

- Same shape as r218's reorderListener

- `set*Listener` / `get*Listener` / `fire*` exports

- On mount, compose with the previous listener; on unmount, restore

- Test: `setXListener` BEFORE mount, then verify the surface updates

- No local sort — always render in the order the singleton has them

- Reorder via `selection.reorderSelected` only (no direct array swap)

- `fireHistoryApplied` puts layers at `detail.layers`

- Tests sometimes use `detail.snap.layers` — support both

- Canvas's `onLayerEnter` fires `fireHoveredLayer`

- TimelineR221's `onRowEnter` also fires `fireHoveredLayer`

- Both surfaces register the chain in their mount effect

- Single-slot model: only the last-mounted listener wins unless they chain

Reflex for r222+

If you need another surface (Inspector, Composer, PresetBrowser) to react to the singleton:

1. Add a new listener channel (e.g. `setActiveToolListener`)

2. Provide `set*Listener`, `get*Listener`, `fire*` exports

3. In the surface's mount effect, chain the listener

4. In the event handler, call `fire*(...)` to broadcast

5. Test: set listener BEFORE mount, verify surface reacts