Skip to content

Re-architect scene runtime: single imperative control plane, retire the master timeline #162

Description

@Brad-Edwards

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:

  1. the GSAP master timeline (pause gates, segment spacers, '>' positioning)
  2. the fire-and-forget async body (wall-clock aSleep)
  3. 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

  • A scene is authored as one async function with one control plane; no scene hand-polls an abort flag or hand-threads a controller into sleeps.
  • Advance / navigate-away deterministically tears down the outgoing scene: no stranded async loop, no audio bleed into the next scene, no lingering chrome/DOM — verified by tests that assert post-advance state, not internal contracts.
  • local-calgary-v2 runs end-to-end through the new runtime, reproducing the demo_thoughts calgary deck beats, without the per-scene defensive boilerplate.
  • GSAP is used only within scenes; no master timeline sequences scenes.
  • audio.ts reduced to the thin bed/cue surface the decks actually use.
  • L2 library (system/chrome, system/templates, system/helpers) preserved; scene authoring stays at or below current LOC.
  • Dead L1 modules and their tests removed; remaining suite is green and exercises rendered/teardown behavior.
  • ADRs updated to reflect single-control-plane model (supersede/amend ADR 003 and the timeline/mode ADRs).

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:audioAudio service, mute, autoplay unlock, sprite playbackarea:compositionComposition manifests, resolution, recompositionarea:presenterPresenter controls, advance/hold/skip, pause/resumearea:timelineTimeline orchestration, named beats, scrub controlenhancementNew feature or requestin-progressAn agent is actively working this issue via /implement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions