Summary
Replace the GSAP-master-timeline runtime with a single imperative control plane, and keep the L2 scene library (system/chrome + system/templates + system/helpers) as-is. The runtime over-committed to a "master timeline sequences scenes" metaphor, but the real cinematic scenes are presenter-driven imperative loops that bypass the timeline and bolt a pause-gate onto it. That mismatch is the structural source of the recurring deck bugs (stranded async loops, audio bleed, lingering DOM on advance).
This is the next step of the rearchitect/scene-runtime work.
Diagnosis (root cause)
The premise (ADR 003) is timeline-driven presentations with a GSAP master timeline as the spine that sequences scenes. But the signature scenes — cold-open, the calgary deck, every real demo deck — are presenter-driven imperative loops (while (!advance) { beat; await sleep; }). The runtime welds the two models together in src/system/templates/presenter-driven.ts:
create() arms an abort flag but does not run the body
timeline() returns a GSAP timeline whose real job is a tl.call() that kicks off the body fire-and-forget, off the timeline tick; the body paces itself with aSleep({ controller }) against a separate PresenterController
- an
addAdvanceGate label is reverse-engineered by composeMasterTimeline into a master addPause
cleanup() flips signal.aborted, which the body must poll between every beat by hand
Net: the scenes that matter bypass timeline.ts (935 LOC) + composition-resolver.ts (753) + scene-loader.ts (840) and run an imperative loop beside them. We pay for the timeline engine and the scenes fight it.
Why this produces "a shit ton of bugs"
Every presenter-driven scene runs against three independent clocks that must agree, by hand:
- the GSAP master timeline (pause gates, segment spacers,
'>' positioning)
- the fire-and-forget async body (wall-clock
aSleep)
- howler audio (its own clock)
In src/decks/local-calgary-v2/scenes/cold-open.ts the author hand-polls if (signal.aborted) return on every beat, hand-threads { controller } into every aSleep, and hand-writes a try/finally to fade audio and wipe chrome. Miss one poll → stranded loop / audio bleed / lingering DOM. The runtime promised to own lifecycle and delegated the hard part — deterministic teardown and abort — back to every scene author.
demo_thoughts decks "just work" (modulo copy-paste) because they have one clock.
Secondary overbuild
src/runtime/audio.ts is 1,400 LOC wrapping howler (own error taxonomy, cue gates, 3 output policies, bed declarations, sprite tuples) — engine serving the engine.
- 6 workbench modes (present / loop / scrub / screenshot / paused / standalone); scrub/screenshot are structurally incompatible with a
setTimeout-driven body and drive much of the L1 weight.
- 1,713 tests / ~48k LOC, all green while decks are buggy — they test internal contracts in happy-dom, not rendered behavior or cross-clock sync. False confidence plus a refactor anchor.
What's good and stays
The L2 layer earns its place. Cold-open is 234 LOC vs 643 in vanilla and reads as beats + animations, not DOM plumbing. Keep:
src/system/chrome, src/system/templates, src/system/helpers — the actual anti-copy-paste asset
- the deck content + CSS already authored
- the
SceneModule contract (src/runtime/scene.ts) and the composition-manifest idea (an ordered scene list — index.ts already is one)
Scope
Build
- A single imperative control plane: a scene is an async function receiving
{ chrome, audio, sleep, signal, gsap } where one controller owns sleep / pause / resume / abort.
- Automatic teardown: a disposal registry the runtime owns, so scenes stop hand-writing
try/finally and abort-polling. Advance/navigate-away tears down deterministically: stop timers, abort in-flight animations, fade+stop scene audio, wipe chrome.
- Sequencing as a plain loop:
run scene → await advance → run next. GSAP stays as a per-scene animation tool the scene calls, not the master spine.
Burn / collapse
- The dual execution model and the master-timeline-as-spine (
composeMasterTimeline, advance-gate hack).
- Most of
composition-resolver.ts + scene-loader.ts → small run-loop.
audio.ts mega-wrapper → thin "play a bed, fade on scene exit" tied to the same controller.
- Speculative modes (loop / scrub / screenshot / standalone) until a real need exists; keep present + prompter.
- The tests that lock in the deleted layer.
Acceptance criteria
Out of scope
- Reintroducing scrub/screenshot/loop modes (separate follow-up if ever needed).
- New deck content.
Risk
- Touches the L1 runtime broadly. Mitigated by keeping the L2 library and scene contract stable as the seam, and migrating
local-calgary-v2 as the proof.
Summary
Replace the GSAP-master-timeline runtime with a single imperative control plane, and keep the L2 scene library (
system/chrome+system/templates+system/helpers) as-is. The runtime over-committed to a "master timeline sequences scenes" metaphor, but the real cinematic scenes are presenter-driven imperative loops that bypass the timeline and bolt a pause-gate onto it. That mismatch is the structural source of the recurring deck bugs (stranded async loops, audio bleed, lingering DOM on advance).This is the next step of the
rearchitect/scene-runtimework.Diagnosis (root cause)
The premise (ADR 003) is timeline-driven presentations with a GSAP master timeline as the spine that sequences scenes. But the signature scenes — cold-open, the calgary deck, every real demo deck — are presenter-driven imperative loops (
while (!advance) { beat; await sleep; }). The runtime welds the two models together insrc/system/templates/presenter-driven.ts:create()arms an abort flag but does not run the bodytimeline()returns a GSAP timeline whose real job is atl.call()that kicks off the body fire-and-forget, off the timeline tick; the body paces itself withaSleep({ controller })against a separatePresenterControlleraddAdvanceGatelabel is reverse-engineered bycomposeMasterTimelineinto a masteraddPausecleanup()flipssignal.aborted, which the body must poll between every beat by handNet: the scenes that matter bypass
timeline.ts(935 LOC) +composition-resolver.ts(753) +scene-loader.ts(840) and run an imperative loop beside them. We pay for the timeline engine and the scenes fight it.Why this produces "a shit ton of bugs"
Every presenter-driven scene runs against three independent clocks that must agree, by hand:
'>'positioning)aSleep)In
src/decks/local-calgary-v2/scenes/cold-open.tsthe author hand-pollsif (signal.aborted) returnon every beat, hand-threads{ controller }into everyaSleep, and hand-writes atry/finallyto fade audio and wipe chrome. Miss one poll → stranded loop / audio bleed / lingering DOM. The runtime promised to own lifecycle and delegated the hard part — deterministic teardown and abort — back to every scene author.demo_thoughts decks "just work" (modulo copy-paste) because they have one clock.
Secondary overbuild
src/runtime/audio.tsis 1,400 LOC wrapping howler (own error taxonomy, cue gates, 3 output policies, bed declarations, sprite tuples) — engine serving the engine.setTimeout-driven body and drive much of the L1 weight.What's good and stays
The L2 layer earns its place. Cold-open is 234 LOC vs 643 in vanilla and reads as beats + animations, not DOM plumbing. Keep:
src/system/chrome,src/system/templates,src/system/helpers— the actual anti-copy-paste assetSceneModulecontract (src/runtime/scene.ts) and the composition-manifest idea (an ordered scene list —index.tsalready is one)Scope
Build
{ chrome, audio, sleep, signal, gsap }where one controller owns sleep / pause / resume / abort.try/finallyand abort-polling. Advance/navigate-away tears down deterministically: stop timers, abort in-flight animations, fade+stop scene audio, wipe chrome.run scene → await advance → run next. GSAP stays as a per-scene animation tool the scene calls, not the master spine.Burn / collapse
composeMasterTimeline, advance-gate hack).composition-resolver.ts+scene-loader.ts→ small run-loop.audio.tsmega-wrapper → thin "play a bed, fade on scene exit" tied to the same controller.Acceptance criteria
local-calgary-v2runs end-to-end through the new runtime, reproducing the demo_thoughts calgary deck beats, without the per-scene defensive boilerplate.audio.tsreduced to the thin bed/cue surface the decks actually use.system/chrome,system/templates,system/helpers) preserved; scene authoring stays at or below current LOC.Out of scope
Risk
local-calgary-v2as the proof.