**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`
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.
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.
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.
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.
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.
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.
| 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 | — |
| 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) |
```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;
```
```ts
export interface TimelineR221Props {
initialLayers?: TimelineR221Layer[]; // default: 4 demo layers
onSelectionChange?: (selectedIds: RegionId[]) => void;
onHoverChange?: (hoveredId: string | null) => void;
}
```
```ts
export interface TimelineR221Layer {
id: string;
name: string;
visible: boolean;
locked: boolean;
color?: string; // optional track color
}
```
- 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
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