diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fa105b..99b8105 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,12 +43,12 @@ jobs: run: pnpm install --frozen-lockfile - name: Install pre-commit run: pip install pre-commit - # typecheck and vitest hooks have dedicated jobs below that also - # upload coverage. Skip them here to avoid duplicate runs. + # typecheck, vitest, and policy hooks have dedicated jobs below. + # Skip them here to avoid duplicate runs. - name: Run pre-commit run: pre-commit run --all-files --show-diff-on-failure env: - SKIP: typecheck,vitest + SKIP: typecheck,vitest,policy typecheck: name: Typecheck @@ -89,6 +89,26 @@ jobs: path: coverage/ retention-days: 7 + policy: + # Structural code-shape audits (PUL-Q001/Q003/Q007, PUL-A001..A010, + # complexity-gate allowlist). Relocated out of the behavior suite + # into their own vitest project so their whole-tree AST scans no + # longer starve under parallel load — enforcement is unchanged and + # this step is blocking. Mirrored by the `policy` pre-commit hook. + name: Policy gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + with: + version: 9.15.0 + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm policy + build: name: Build runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3e83a23..20ff685 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,3 +62,14 @@ repos: language: system pass_filenames: false files: ^(src/.*\.ts|tests/.*\.ts|vitest\.config\.ts|package\.json)$ + + # Structural code-shape audits, relocated out of the behavior + # suite into their own vitest project (vitest.policy.config.ts). + # Blocking — same enforcement set as before, just no longer mixed + # into `pnpm test`. Mirrored by the CI `policy` job. + - id: policy + name: pnpm policy (source-policy gate) + entry: bash -c 'pnpm policy' + language: system + pass_filenames: false + files: ^(src/.*\.ts|tests/.*\.ts|biome\.json|vitest\.policy\.config\.ts|package\.json)$ diff --git a/changelog.d/+audio-engine-slimming.changed.md b/changelog.d/+audio-engine-slimming.changed.md new file mode 100644 index 0000000..ac4a6d2 --- /dev/null +++ b/changelog.d/+audio-engine-slimming.changed.md @@ -0,0 +1,28 @@ +Closed the three `src/runtime/audio.ts` cognitive-complexity +suppressions. `unlock()` now delegates its HTML5 and Web Audio +fallback branches to the `unlockHtml5Fallback` / `resumeWebAudioContext` +module helpers; `play()` delegates option validation to `validatePlay` +and per-instance engine output to `applyPlayToHandle`. The +`normalizeSources` offender was already covered by the hoisted +`normalizeAudioUrl`. Behavior is unchanged — the audio service public +methods, output policies, error families, composition bed routing, cue +gate, and master-mute semantics are byte-identical. The audio.ts rows +were removed from `docs/design/complexity-backlog.md` and the +complexity-gate policy oracle. + +Slimmed the audio service internals without changing observable +behavior: the per-service `disposed` boolean and its scattered guards +were replaced by a single internal `AbortController` so the navigation +signal and an explicit `stopAll()` converge on one disposal gate and one +teardown; the composition bed is registered through the same +`registerSound` core scene sounds use under the reserved, non-kebab +`composition audio bed` id (the bespoke `startBed` arrow and `bedAllowed` +local are gone — the bed is now started inline through the shared core, +gated against its own declared `src`), keeping it unreachable from +`ctx.audio`; and the four per-option `assertPlayOption*` helpers were +folded into a table-driven `assertPlayOptions`. The validated +`outputPolicy` string is now collapsed once at construction into the two +orthogonal output axes the policy actually controls — `muted` (engine +mute) and `emitCues` (rehearsal cue sink) — so no service method +re-derives behavior from the policy string. The public `outputPolicy` +option and its allowlist validation are unchanged. diff --git a/changelog.d/+audio-error-and-validator-consolidation.changed.md b/changelog.d/+audio-error-and-validator-consolidation.changed.md new file mode 100644 index 0000000..96242bc --- /dev/null +++ b/changelog.d/+audio-error-and-validator-consolidation.changed.md @@ -0,0 +1,8 @@ +Collapsed the audio error hierarchy and de-duplicated the shape-validator tests. The nine `AudioError` subclasses +(`AudioSoundError` / `AudioGroupError` / `AudioRangeError` / `AudioSourceError`, etc.) are gone — no `src/` caller +discriminated them — replaced by one `AudioError` carrying a `category` discriminant (`sound` / `group` / `source` / +`range` / `option`) and module-private per-category constructors. `ctx.audio` runtime behavior, the throwable surface, +and every rejected input are unchanged. The brittle field-by-field `.each` validation loops for `assertSceneModule`, +`assertCompositionManifest`, and `assertAudioBedDeclaration` are replaced by one representative assert per shape plus a +shared seeded property fuzz (`tests/runtime/validator-fuzz.ts`) covering the same malformed-input classes (omission, +wrong type, out-of-range number, non-kebab id) and asserting the offending field is named. diff --git a/changelog.d/+authoring-real-dom.changed.md b/changelog.d/+authoring-real-dom.changed.md new file mode 100644 index 0000000..9e1ac8b --- /dev/null +++ b/changelog.d/+authoring-real-dom.changed.md @@ -0,0 +1,14 @@ +L2 template/scene authoring now uses real `lib.dom` types. The +structural fake-DOM types (`TemplateDomElement` / `TemplateDomFactory` +/ `TemplateStageElement`) and the per-call defensive ctx narrowing +(`isTemplateCtx` / `isStageShape` / `isGsapShape`) are gone; templates +take `HTMLElement` / `Document` directly and read `ctx` through one +`asTemplateCtx` view that checks only the genuine off-DOM +(`stage === null`) path. Every `as unknown as HTMLElement|Document` +cast in templates and decks is removed. Decks reference a shared +`TemplateTimeline` type instead of re-declaring a structural timeline +subset. The deck-only templates `operatorDossier`, `incidentPlate`, +and `haulCitations` moved into `src/decks/local-calgary-v2/templates/` +(with their CSS) since no other deck uses them. DOM-touching template +tests opt into `happy-dom` per file and assert against real rendering; +the runtime fake-stage suites stay node-env and unchanged. diff --git a/changelog.d/+comment-sweep.changed.md b/changelog.d/+comment-sweep.changed.md new file mode 100644 index 0000000..163aa9b --- /dev/null +++ b/changelog.d/+comment-sweep.changed.md @@ -0,0 +1,8 @@ +Collapsed narrative/ceremony comments across the runtime to terse +one-line contracts. Trimmed module preambles, per-field JSDoc on +`WorkbenchSceneCtx` / `SceneLoaderOptions` / `LoadSceneNavigationTargetOptions`, +and review-cycle narration in `src/main.ts` and `src/runtime/{scene-loader, +scene-loader-ctx,scene-loader-guard,scene,navigation,scene-navigation, +composition-resolver,composition,validation,audio,timeline,presenter, +prompter,workbench-chrome}.ts`. Comments only — no code, behavior, or +public-API change. diff --git a/changelog.d/+composition-resolver-internals.changed.md b/changelog.d/+composition-resolver-internals.changed.md new file mode 100644 index 0000000..da37402 --- /dev/null +++ b/changelog.d/+composition-resolver-internals.changed.md @@ -0,0 +1,23 @@ +Refactored the composition-resolver and scene-navigation internals +without changing behavior. `SceneActivation` identity is now embedded +in each plan step at build time (no per-call reconstruction); the +three finalizers collapse into one `finalize()` that selects +aggregate-vs-reraise from whether `onSceneFailed` was supplied; +`onSceneFailed` is wrapped once at lifecycle-context build; the bare +mount→compose→run→cleanup engine (`runLifecycle`) is separated from +the scene-failure-isolation concern, which now lives in a named +`withFailureIsolation` decorator (owning the failure bucket, the +once-wrapped `onSceneFailed`, and the aggregate-vs-reraise routing) +that `resolveComposition` is composed from; the three composition- +resolution paths (from-start / scene / index) unify behind one +parameterized index finder; and the resolver's run-option builder is +reduced to a single strip. The bare engine (`runLifecycle`, +`orchestrate` via `buildPlan` + `buildLifecycleContext`) is exported so +it can be driven directly in tests with a plain collecting +`reportFailure`. Added both-path (onSceneFailed supplied / omitted) +regression coverage asserting cleanup-exactly-once-per-activation and +correct error routing for create-throw, timeline-throw, cleanup-throw, +abort-mid-mount, and a repeated scene id where one occurrence fails, +plus a bare-engine suite that drives `runLifecycle` without the +decorator. Public signatures, `data-pulsar-*` attributes, and error +wording are unchanged. diff --git a/changelog.d/+mode-profile-table.changed.md b/changelog.d/+mode-profile-table.changed.md new file mode 100644 index 0000000..21bdf26 --- /dev/null +++ b/changelog.d/+mode-profile-table.changed.md @@ -0,0 +1,10 @@ +Centralized per-mode workbench behavior into a single `ModeProfile` +data table (`src/runtime/mode-profile.ts`). The audio output policy, +composition-slice truncation, chrome visibility, audio-bed suppression, +scrub cue gate, and head-scene runner hints were previously scattered +as `mode === X` branches across `scene-loader.ts` and +`workbench-chrome.ts`; they now read one frozen profile per mode. +Behavior is unchanged (snapshot-equivalence test), and collapsing the +four runner-hint ternaries into a single `...runnerHints` spread dropped +`runLifecycle` below the cognitive-complexity gate, closing a +complexity-backlog entry. diff --git a/changelog.d/+policy-gate-relocation.changed.md b/changelog.d/+policy-gate-relocation.changed.md new file mode 100644 index 0000000..c0b1904 --- /dev/null +++ b/changelog.d/+policy-gate-relocation.changed.md @@ -0,0 +1,14 @@ +Relocated the policy / source-scan suites +(`tests/runtime/policy-*.test.ts`, +`tests/runtime/screenshot-determinism-source.test.ts`, and their shared +`source-policy.ts` AST framework) out of the default behavior suite +into a dedicated, still-blocking gate. They run via a new `pnpm policy` +script against `vitest.policy.config.ts` (serial, generous timeout) and +are excluded from `vitest.config.ts`, so `pnpm test` is now +behavior-only and no longer flakes on the structural AST scans starving +under parallel load (PUL-Q003 / PUL-Q007 5s timeouts). Enforcement is +unchanged: `pnpm policy` is wired as a blocking job in +`.github/workflows/ci.yml` and a blocking hook in +`.pre-commit-config.yaml`, running the identical violation set. No +policy check was dropped or weakened; the Biome complexity-gate +override for the cluster is untouched. diff --git a/changelog.d/+presenter-transport-extraction.changed.md b/changelog.d/+presenter-transport-extraction.changed.md new file mode 100644 index 0000000..a9ee3a1 --- /dev/null +++ b/changelog.d/+presenter-transport-extraction.changed.md @@ -0,0 +1,10 @@ +Extracted the presenter transport machinery (advance / hold / skip / +pause / resume command translation) out of the always-on timeline +composition path into a dedicated opt-in module +(`src/runtime/presenter-transport.ts`). The transport is wired onto the +master timeline only when a navigation forwards a presenter controller +(`mode=present`); a non-present navigation never instantiates it. +`timeline.ts` keeps the GSAP composition spine — `composeMasterTimeline`, +the scene label namespace, `assertSceneTimeline`, and the `MasterBeat` +beat query. No public signatures, `data-pulsar-*` attributes, or +cross-engine timing behavior changed. diff --git a/changelog.d/+scene-loader-decomposition.changed.md b/changelog.d/+scene-loader-decomposition.changed.md new file mode 100644 index 0000000..0d227d5 --- /dev/null +++ b/changelog.d/+scene-loader-decomposition.changed.md @@ -0,0 +1,12 @@ +Decomposed the scene-loader god-functions (`buildLoad`, `runTarget`) +into cohesive single-responsibility units below the cognitive-complexity +gate, deleting both `noExcessiveCognitiveComplexity` suppressions. +Per-navigation audio service / presenter pipe / ctx factory moved to +`src/runtime/scene-loader-ctx.ts`; the present-mode audio unlock-gate +predicate and the composition chrome dispatch policy to +`src/runtime/scene-loader-guard.ts`. The `beat` / `mode` grammar rules +are now sourced from a single `NAVIGATION_GRAMMAR` object in +`src/runtime/navigation.ts`, consumed by both `parseNavigationSearch` +and the loader's defense-in-depth re-check (the forged-target trust +seam is retained). `createSceneLoader`, all exported types, the +`data-pulsar-*` stage attributes, and runtime behavior are unchanged. diff --git a/changelog.d/+simplify-audio-timeline.changed.md b/changelog.d/+simplify-audio-timeline.changed.md new file mode 100644 index 0000000..e6d0cc0 --- /dev/null +++ b/changelog.d/+simplify-audio-timeline.changed.md @@ -0,0 +1,13 @@ +Simplified the audio/timeline runtime cluster without behavior change: +inlined the single-use `validateSpeed` / `validateRepeat` validators +into `GsapMasterTimeline.setSpeed` / `.repeat`, rebuilt +`buildRunComposeOptions` as a single conditional-spread literal instead +of an empty object with four `as`-cast field assignments, and collapsed +the `...(x === undefined ? {} : { x })` idiom to `...(x && { x })` for +the object/boolean-typed `sprite` / `mute` fields in +`createHowlerAudioEngine`. Also trimmed the audio-unlock-dom module +preamble, the orphaned/duplicated adapter JSDoc, and the codex-cycle +narration to terse contracts. Public signatures (`AudioService` / +`AudioError` / `MasterTimeline` / `TimelineEngine` / the audio-unlock +adapter), error strings, `data-pulsar-*` attributes, and the abort-race +isolation guard are unchanged. diff --git a/changelog.d/+simplify-runtime-patterns.changed.md b/changelog.d/+simplify-runtime-patterns.changed.md new file mode 100644 index 0000000..63de4ab --- /dev/null +++ b/changelog.d/+simplify-runtime-patterns.changed.md @@ -0,0 +1,11 @@ +Simplified the scene-loader and navigation/composition runtime cluster +without behavior change: inlined linter-noise micro-helpers +(`setStageAttr` / `clearStageAttr` / `audioOutputPolicyFor` / +`audioServiceOptions`), collapsed the `...(x === undefined ? {} : { x })` +conditional-spread idiom to its positive `...(x ? { x } : {})` form, +replaced writable-intermediate-then-freeze object construction in +`parseNavigationSearch` / `composeSegments` / `buildPrompterScript` with +direct frozen literals, and merged the two-stage `chromeBehavior` +extraction in `scene-loader-guard`. Public signatures, `data-pulsar-*` +attributes, error strings, and PUL-Q008 attribute-literal handling are +unchanged. diff --git a/changelog.d/+simplify-system-bootstrap.changed.md b/changelog.d/+simplify-system-bootstrap.changed.md new file mode 100644 index 0000000..d9715b0 --- /dev/null +++ b/changelog.d/+simplify-system-bootstrap.changed.md @@ -0,0 +1,8 @@ +Made `createDomWorkbenchChrome` and `createDomAudioUnlockAdapter` generic +over their concrete element type so `src/main.ts` mounts a real +`HTMLElement` / `HTMLButtonElement` without `as unknown as Node` casts. +Trimmed narrative/ceremony comments in the chrome, prompter-window, +practice-renderer, and keyboard-source modules, and collapsed the +repeated `tl.fromTo` reveal boilerplate in the pulsar-intro deck behind a +local `reveal` helper (byte-identical timeline output). Behavior, exported +signatures, DOM attributes, and tests are unchanged. diff --git a/changelog.d/+template-coverage.added.md b/changelog.d/+template-coverage.added.md new file mode 100644 index 0000000..92ecccf --- /dev/null +++ b/changelog.d/+template-coverage.added.md @@ -0,0 +1,11 @@ +Behavioral test coverage for the under-tested L2 template library and +runtime timing helpers. New mount-and-assert suites exercise the real +rendered DOM, authored timeline beats, and cleanup for `terminal`, +`card-carousel`, `activity-feed-payoff`, `chat-pick-list`, +`split-dialogue-email`, `split-pane-terminal-doc`, `metric-ticker`, the +`_shared` template envelope, the `register` token barrel, and the +abortable-timing primitives in `helpers/timing`. Product line coverage +(runtime + template library) rises from ~93% to ~99%; `terminal.ts` +alone goes 35% to 99%. The example decks (demonstration content, +exercised by the Playwright E2E) are scoped out of the coverage gate — +the gate measures the product, not the sample decks. diff --git a/changelog.d/+test-fakes-fixture.changed.md b/changelog.d/+test-fakes-fixture.changed.md new file mode 100644 index 0000000..78d7186 --- /dev/null +++ b/changelog.d/+test-fakes-fixture.changed.md @@ -0,0 +1,11 @@ +Extracted the duplicated jsdom-free test fakes into a single +`tests/support/fakes.ts` fixture: the `mode=*` fixture-scene stage stub +(was re-derived byte-for-byte in five `tests/scenes/*-fixture.test.ts` +files), the synthetic `HTMLElement`/`Document` tree the chrome pack +tests use (was duplicated across `chrome-slots` and `chrome-extras`), +and the event-emitting / no-op presenter controllers (was re-rolled in +`helpers` and `presenter-driven`). Typing the chrome fake as the real +DOM interfaces dropped every `as unknown as HTMLElement|FakeElement` +cast at the chrome-test call sites, and trimmed the copy-pasted +coverage-narration headers to one line each. Assertions, test counts, +and behavior coverage are unchanged. diff --git a/changelog.d/162.changed.md b/changelog.d/162.changed.md new file mode 100644 index 0000000..5117460 --- /dev/null +++ b/changelog.d/162.changed.md @@ -0,0 +1,8 @@ +L1 runtime rewrite (ADR-032): scenes now run through a single imperative +control plane (`runSceneModules` in `src/runtime/spike/control-plane.ts`), +driven per navigation by `createPresentLoader` (`src/runtime/present-loader.ts`) +which `src/main.ts` boots through. Each scene mounts via `create`, plays its +own GSAP timeline standalone to completion, holds for presenter advance, then +tears down via `cleanup` — replacing the master-timeline composition resolver. +L2 template teardown moved from the timeline into the scene `cleanup` hook so +it fires on scene exit rather than at the timeline's natural end. diff --git a/changelog.d/162.removed.md b/changelog.d/162.removed.md new file mode 100644 index 0000000..16250e1 --- /dev/null +++ b/changelog.d/162.removed.md @@ -0,0 +1,6 @@ +Removed the master-timeline layer and speculative workbench modes (ADR-032): +the `standalone`, `loop`, `paused`, `scrub`, `screenshot`, and `rehearsal` +modes (only `present` and `prompter` remain), the master-timeline composition +resolver + presenter transport, the GSAP master composer/advance gates, +inter-scene transitions, and the scrub controls. The `mode=` grammar, +mode-profile table, and the speculative scene fixtures were trimmed to match. diff --git a/docs/adrs/032-single-imperative-scene-control-plane.md b/docs/adrs/032-single-imperative-scene-control-plane.md new file mode 100644 index 0000000..2362f1d --- /dev/null +++ b/docs/adrs/032-single-imperative-scene-control-plane.md @@ -0,0 +1,167 @@ +# ADR-032: Single Imperative Scene Control Plane + +## Status + +Accepted + +Supersedes the master-timeline sequencing decisions in +[ADR-003](003-gsap-timeline-engine.md), +[ADR-011](011-composition-resolver-orchestration.md), +[ADR-025](025-timeline-adapter-boundary.md), and the timeline-runner +portions of [ADR-015](015-url-beat-positioning.md), +[ADR-016](016-workbench-mode-present.md) through +[ADR-021](021-workbench-mode-screenshot.md), +[ADR-023](023-presenter-controls.md), [ADR-024](024-presenter-pause-resume.md), +and [ADR-026](026-named-timeline-beats.md). + +The scene/composition contracts, registries, URL parsing boundary, +asset policy, validation pass, scene failure isolation, audio unlock +gate, browser-support contract, and workbench chrome ownership decisions +from those ADRs stand unless this ADR explicitly replaces them. + +## Date + +2026-06-07 + +## Context + +The accepted runtime model made GSAP master timelines the sequencing +spine. Real authored decks then converged on presenter-driven async +scene bodies: loop over beats, sleep on wall-clock timers, react to +presenter advance, and clean up audio/chrome in hand-written `finally` +blocks. + +That produced two incompatible execution models: + +- The runtime composed scene timelines into a master and paused at + labels. +- The scene body ran as fire-and-forget async work beside that master. + +The resulting three clocks -- master timeline, async sleeps, and Howler +audio -- made teardown author-discipline-dependent. Missing one abort +check or cleanup call could leave a loop, sound, listener, or DOM subtree +alive after navigation. + +Issue 162 reverses that architectural direction. The runtime should own +one imperative control plane and keep the L2 scene library +(`src/system/chrome`, `src/system/templates`, `src/system/helpers`) as +the authoring asset. + +## Decision + +Pulsar uses a single imperative control plane per scene activation. + +A scene activation is driven by one runtime-owned controller that owns: + +- sleep / pause / resume / advance; +- navigation abort; +- registered timers, listeners, GSAP animations, audio handles, and + disposable callbacks; +- scene exit teardown. + +Scene authors write an async scene body that receives a bounded context +containing the existing scene-facing capabilities: chrome, audio, sleep, +signal, gsap, stage, presenter information where applicable, rng, and +activation identity. The existing `SceneModule` registration contract and +composition manifest remain the registry boundary; L2 templates may adapt +that async body into a `SceneModule`, but implementation must not create +a second scene schema or a parallel registry. + +Composition sequencing is a plain ordered loop over the manifest slice: +validate and resolve the target, preload declared assets, activate one +scene, wait for that scene to finish or for presenter advance, tear it +down, then activate the next scene. The runtime no longer composes a GSAP +master timeline to sequence scenes. + +GSAP remains available only as a per-scene animation tool through +`ctx.gsap`. Scene code must not import GSAP directly, and no runtime +master timeline owns scene sequencing, presenter advance gates, URL beat +positioning, scrub transport, or inter-scene lifecycle. + +Audio remains behind the runtime-owned Howler boundary, but the +scene-facing service is reduced to the deck surface actually needed: +load/play/fade/stop scene cues and beds, validate sources through the +asset policy, and stop/fade/unload on scene exit. The runtime, not the +scene, performs final audio teardown. + +The active browser runtime modes are `present` and `prompter`. The +previous speculative execution modes (`standalone`, `loop`, `paused`, +`scrub`, `screenshot`, `rehearsal`) are not part of the active control +plane. If a future requirement reintroduces one, it must extend the +loader/control-plane policy seam deliberately; it must not restore a +master timeline or add scene-local mode branches. + +Presenter command validation remains centralized in +`src/runtime/presenter.ts`. Command-to-control-plane behavior belongs to +the runtime controller. Scene bodies must not install their own presenter +command buses, global keyboard listeners, or pause/advance state. + +Automatic teardown is a runtime invariant. Scene bodies should not +hand-poll an abort flag between beats, hand-thread a controller into +every sleep, or hand-write `try/finally` solely to clean runtime-owned +timers, audio, chrome, and animations. A scene may still use `finally` +for scene-local state that is not expressible through the runtime +disposal registry, but that is an escape hatch, not the normal lifecycle. + +## Consequences + +### Positive + +- There is one clock and one owner of scene lifecycle. +- Advance and navigation teardown can be tested against rendered DOM and + audible/registered audio state, not internal timeline contracts. +- The L2 chrome/template/helper layer remains the authoring leverage, + while the L1 runtime stops forcing it through a mismatched master + timeline model. +- Scene authors get a smaller surface: async body plus runtime-owned + sleep/teardown instead of timeline labels, advance gates, controller + threading, and manual abort polling. +- The scene registry, composition manifest, validation, asset policy, + URL parser, presenter command validator, and workbench chrome boundary + remain reusable. + +### Negative + +- Existing timeline-runner tests and mode-specific tests that assert the + deleted master-timeline layer must be removed or rewritten as rendered + lifecycle tests. +- URL beat positioning, scrub transport, screenshot mode, loop mode, + paused inspection, and rehearsal cue logging are no longer active + runtime contracts. +- Scenes that encoded useful per-scene GSAP animation in `timeline(ctx)` + must migrate that animation into the async body or an L2 helper without + using a master timeline for scene sequencing. + +### Risks + +| Risk | Mitigation | +|------|------------| +| The implementation keeps both models alive during migration | Delete or quarantine master sequencing entrypoints as part of the runtime change; tests must prove no master timeline sequences scenes. | +| A disposal registry becomes a new hidden framework | Keep it activation-scoped and concrete: timers, listeners, animations, audio, DOM/chrome cleanup, and explicit disposables. Do not add a generic plugin lifecycle. | +| `SceneModule` and async-body authoring become duplicate schemas | Keep `SceneModule` as the registry/validation contract. Put async-body ergonomics in L2 templates or a single runtime adapter, not a second registry type. | +| Source-policy scans miss deck-local scenes | Apply the same scene-module policy to `src/decks/**/scenes/**` or add targeted deck tests for the proof deck. | +| Public diagnostics leak raw scene state during teardown failures | Reuse `describeError`, `describeErrorDetailed`, `formatSceneContext`, `onError`, and stage attributes; never serialize raw causes, DOM, audio handles, headers, cookies, env, or stacks. | +| Future work reintroduces scrub/screenshot by restoring the master timeline | Reintroduce modes only through a new ADR/requirement that defines a control-plane policy seam and rendered behavior tests. | + +## Related ADRs + +- [ADR-001](001-custom-experience-runtime.md) -- runtime ownership of + scene lifecycle remains. +- [ADR-002](002-scene-registry-and-compositions.md) -- scene registry and + composition manifests remain the core abstraction. +- [ADR-004](004-howler-audio-engine.md) -- Howler remains the engine + boundary, but the scene-facing surface is slimmed and teardown is + control-plane-owned. +- [ADR-008](008-agent-native-authoring.md) -- small scene contract, + stable ids, manifests, and mandatory cleanup remain binding. +- [ADR-012](012-asset-preloader-fetch-and-drain.md) -- asset URL policy + remains the security boundary for asset and audio sources. +- [ADR-013](013-url-navigation-grammar-boundary.md) and + [ADR-014](014-url-scene-target-selection.md) -- URL parsing and target + resolution remain before lifecycle execution. +- [ADR-028](028-scene-level-error-isolation.md) -- lifecycle failures + remain runtime-owned diagnostics, not scene-local recovery workflows. +- [ADR-029](029-present-mode-audio-unlock-gate.md) -- present-mode audio + unlock remains a loader/workbench gate before audio-bearing playback. +- [ADR-031](031-workbench-chrome-surface.md) -- chrome remains + workbench-owned and scene-accessed only through slots. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 6764255..aa99b66 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -57,3 +57,4 @@ Each ADR includes: | [029](029-present-mode-audio-unlock-gate.md) | Present-Mode Audio Unlock Gate Before Composition Start | Accepted | | [030](030-browser-support.md) | Browser Support Contract | Accepted | | [031](031-workbench-chrome-surface.md) | Workbench Chrome Surface — Workbench-Owned, Mode-Governed, Persistent | Accepted | +| [032](032-single-imperative-scene-control-plane.md) | Single Imperative Scene Control Plane | Accepted (supersedes master-timeline sequencing and speculative mode decisions) | diff --git a/docs/design/README.md b/docs/design/README.md index 9fe92bd..640549c 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -14,6 +14,7 @@ Design context for Pulsar. Source material for the ADRs in `../adrs/`. | [issue-100-runtime-comment-audit-preflight.md](issue-100-runtime-comment-audit-preflight.md) | Guardrails for auditing stale runtime comments without changing behavior, duplicating ADR rationale, or weakening source-policy comments. | | [issue-101-production-asset-policy-preflight.md](issue-101-production-asset-policy-preflight.md) | Guardrails for documenting and optionally enforcing production asset URL, redirect, base URL, cross-origin, and credential policy through the existing preloader and validation seams. | | [issue-102-scene-module-trust-boundary-preflight.md](issue-102-scene-module-trust-boundary-preflight.md) | Guardrails for documenting that scene modules are trusted application code and that validation/source/asset policies are not sandboxing. | +| [issue-162-single-control-plane-preflight.md](issue-162-single-control-plane-preflight.md) | Guardrails for replacing the GSAP master timeline with one imperative control plane while preserving the scene/composition contracts and L2 authoring layer. | | [positioning-and-landscape.md](positioning-and-landscape.md) | Category, adjacent OSS projects, differentiation, strategic risks. | | [pul-p002-validation-ci-preflight.md](pul-p002-validation-ci-preflight.md) | Guardrails for gating pull-request CI on the canonical runtime validation pass. | | [pul-p003-adr-format-preflight.md](pul-p003-adr-format-preflight.md) | Guardrails for keeping ADR markdown and Ground Control ADR records aligned without duplicate schemas or workflow logic. | diff --git a/docs/design/complexity-backlog.md b/docs/design/complexity-backlog.md index a175b60..503496a 100644 --- a/docs/design/complexity-backlog.md +++ b/docs/design/complexity-backlog.md @@ -50,25 +50,46 @@ function per site. | File | Symbol | Score | |------|--------|-------| | [`src/runtime/asset-preloader.ts`](../../src/runtime/asset-preloader.ts) | returned async `(scene) => ...` arrow inside `createAssetPreloader` | 16 | -| [`src/runtime/audio.ts`](../../src/runtime/audio.ts) | `async unlock()` method on the AudioUnlocker | 22 | -| [`src/runtime/audio.ts`](../../src/runtime/audio.ts) | `normalizeSources` arrow | 17 | -| [`src/runtime/audio.ts`](../../src/runtime/audio.ts) | `play(soundId, options)` method | 24 | -| [`src/runtime/scene-loader.ts`](../../src/runtime/scene-loader.ts) | `buildLoad` arrow | 18 | -| [`src/runtime/scene-loader.ts`](../../src/runtime/scene-loader.ts) | `runLifecycle` arrow | 21 | -| [`src/runtime/scene-loader.ts`](../../src/runtime/scene-loader.ts) | `runTarget` async arrow | 23 | + +The three `src/runtime/audio.ts` offenders — `async unlock()` (22), +`normalizeSources` (17), and `play(soundId, options)` (24) — were +removed when the audio engine was slimmed: `unlock()` delegates to the +`unlockHtml5Fallback` / `resumeWebAudioContext` module helpers, the +per-URL validation lives in the hoisted `normalizeAudioUrl`, and +`play()` delegates option validation to `validatePlay` and engine +output to `applyPlayToHandle`. Their site-level suppressions were +deleted with them. + +`runLifecycle` (formerly score 21) was removed from this list when the +per-mode runner hints (`repeat` / `hold` / `cueGate` / `screenshot`) +collapsed from four `mode === X` ternaries into a single +`...runnerHints` spread sourced from +[`src/runtime/mode-profile.ts`](../../src/runtime/mode-profile.ts). Its +site-level suppression was deleted with it. + +`buildLoad` (score 18) and `runTarget` (score 23) were removed when the +scene loader was decomposed into cohesive single-responsibility units: +per-navigation audio/presenter/ctx construction moved to +[`src/runtime/scene-loader-ctx.ts`](../../src/runtime/scene-loader-ctx.ts), +the unlock-gate predicate and chrome dispatch policy to +[`src/runtime/scene-loader-guard.ts`](../../src/runtime/scene-loader-guard.ts), +and the `beat` / `mode` grammar re-check unified onto the shared +`NAVIGATION_GRAMMAR` source in +[`src/runtime/navigation.ts`](../../src/runtime/navigation.ts). Both +site-level suppressions were deleted with them. ### Test fixtures and helpers -These three test offenders are isolated functions, not part of the -policy-scanner cluster, and are not covered by the file-level -overrides below. They get site-level suppressions like the production -source above. They are listed here so the ratchet has the same -target/score record for them. +This test offender is an isolated function, not part of the +policy-scanner cluster, and is not covered by the file-level +overrides below. It gets a site-level suppression like the production +source above. It is listed here so the ratchet has the same +target/score record for it. (The `scene-loader.helpers.ts` `asTimeline` +and `scene-loader-present.test.ts` `mountPresent` offenders were removed +with the master-timeline scene-loader test suite under ADR-032.) | File | Symbol | Score | |------|--------|-------| -| [`tests/runtime/scene-loader.helpers.ts`](../../tests/runtime/scene-loader.helpers.ts) | `asTimeline` adapter's `run` method | 21 | -| [`tests/runtime/scene-loader-present.test.ts`](../../tests/runtime/scene-loader-present.test.ts) | `mountPresent` mount helper | 22 | | [`tests/scenes/dom-css-accessibility-fixture.test.ts`](../../tests/scenes/dom-css-accessibility-fixture.test.ts) | recursive `walk` accessibility-attribute scan | 20 | ## File-level overrides (no ratchet target) @@ -90,6 +111,17 @@ targets at all, by design. The override allowlist is enforced by `tests/runtime/policy-biome-complexity-gate.test.ts` so the cluster cannot be quietly widened. +This policy-scanner cluster (`policy-*.test.ts`, `source-policy.ts`, +`screenshot-determinism-source.test.ts`) does **not** run in the +default behavior suite (`pnpm test`). It is a separate, still-blocking +gate run via `pnpm policy` (`vitest.policy.config.ts`), wired into both +CI (the `policy` job) and the `policy` pre-commit hook. Its whole-tree +AST scans starved under the behavior suite's parallel load and +intermittently timed out, so the gate was relocated — the enforcement +set is unchanged. The Biome override above stays regardless of where +the suite runs; `biome.json` remains the canonical complexity-gate +declaration. + ## Ratchet plan The intent of the gate is to lower `maxAllowedComplexity` as the diff --git a/docs/design/issue-162-single-control-plane-preflight.md b/docs/design/issue-162-single-control-plane-preflight.md new file mode 100644 index 0000000..67844b6 --- /dev/null +++ b/docs/design/issue-162-single-control-plane-preflight.md @@ -0,0 +1,187 @@ +# Issue 162 Single Control Plane Preflight + +Date: 2026-06-07 + +Issue 162 is a runtime re-architecture. It replaces the GSAP master +timeline execution model with one imperative control plane while +preserving the L2 scene library and the existing scene/composition +authoring contracts where they still carry value. + +This document is architecture guidance only. It is not an implementation +plan. + +## Boundary + +- Keep `SceneModule`, scene registries, composition registries, and + composition manifests as the declaration and validation boundary. +- Keep `src/system/chrome`, `src/system/templates`, and + `src/system/helpers` as the L2 authoring layer. +- Replace the runtime execution spine: no `composeMasterTimeline`, no + master `addPause` advance gate, no timeline-runner mode hints, and no + fire-and-forget async body running beside a master timeline. +- Present mode runs the ordered manifest through one scene activation at + a time. Prompter remains the captions-only lifecycle bypass. +- GSAP remains a per-scene animation library injected through `ctx.gsap`; + it must not sequence scenes. +- Howler remains behind the runtime audio boundary, but the scene-facing + service should be the thin bed/cue/fade/stop surface used by decks. + +## Required Reuse + +Implementation must build on these incumbents: + +- Scene schema: `SceneModule`, `defineScene`, `assertSceneModule()`, + `sceneDeclaresAudio()`. +- Composition schema: `CompositionManifest`, + `assertCompositionManifest()`, `entryId()`, + `findUnregisteredEntries()`, `createCompositionRegistry()`. +- Registry shape: `createSceneRegistry()`, `createIdRegistry()`, + kebab identity via `isKebabIdentifier()` and `KEBAB_IDENTIFIER_FORM`. +- Navigation boundary: `parseNavigationSearch()`, + `validateBeatGrammar()`, `validateModeGrammar()`, `effectiveMode()`, + `resolveSceneNavigation()`, and stage diagnostics in + `createSceneLoader()`. +- Validation: `validateRuntime()` and `assertNoValidationFindings()`. + Do not add a second graph validator for the new control plane. +- Lifecycle/error surface: `SceneActivation`, `SceneFailureEvent`, + `describeError()`, `describeErrorDetailed()`, `formatSceneContext()`, + `onError`, `AggregateError`, and `Error.cause`. +- Scene context: `WorkbenchSceneCtx`, `buildNavigationServices()`, + `ctx.stage`, `ctx.chrome`, `ctx.audio`, `ctx.presenter`, `ctx.gsap`, + `ctx.rng`, and `ctx.activation`. +- Presenter boundary: `PRESENTER_COMMAND_KINDS`, `PresenterCommand`, + `isPresenterCommand()`, `PresenterCommandSource`, + `PresenterController`, and `createPresenterController()`. +- Audio boundary: `createAudioService()`, `AudioService.stopGroup()`, + `AudioService.stopAll()`, `createHowlerAudioEngine()`, + `noopAudioEngine`, `AudioError`, `AUDIO_OUTPUT_POLICIES`, and + `resolveAssetUrl()`. +- Asset policy: `scene.assets`, `scene.audio`, + `createAssetPreloader()`, `AssetUrlPolicy`, + `DEFAULT_ALLOWED_SCHEMES`, and post-redirect scheme re-checks. +- Chrome/presenter L2: `mountChromeSlots()`, `createDomWorkbenchChrome()`, + `createKeyboardPresenterSource()`, `createPresenterBridge()`, + `combinePresenterSources()`, and existing chrome helpers. +- Test gates: Vitest runtime/system suites, source-policy suites under + `pnpm policy`, Playwright browser tests under `pnpm test:browsers`, + plus `pnpm lint` and `pnpm typecheck`. + +## Cross-Cutting Layers + +| Layer | Guardrail | +|-------|-----------| +| Scene trust model | Scene modules remain trusted repo-owned code. Do not claim the new control plane sandboxes scene bodies. `docs/scene-trust-model.md` remains binding. | +| Scene schema gate | `assertSceneModule()` remains the one scene-shape validator. If the async body needs an adapter, put it in one L2/runtime helper that returns a valid `SceneModule`; do not add `ImperativeSceneModule`, `RunnableScene`, or a parallel registry. | +| Composition graph | Keep manifests declarative and ordered. No `next()` callbacks, per-scene flow control objects, hidden scene lists, or dynamic registration. | +| URL grammar | URL parsing remains in `navigation.ts`. Removing speculative modes must update `NAVIGATION_MODES`, mode validation, docs, tests, and chrome/audio policy together. Do not store mode, pause, or advance state in URL/history/storage. | +| Runtime validation | `validateRuntime()` remains pure metadata validation. It must not execute scene bodies, fetch assets, inspect function source, or prove code safety. | +| Asset security | Audio and other resources continue through `scene.assets`, `scene.audio`, `resolveAssetUrl()`, `baseUrl`, `allowedSchemes`, and the preloader. Do not introduce `audioSrc`, arbitrary `play(url)`, or bed URLs outside the existing policy. | +| Audio unlock | Present-mode audio unlock stays at the loader/workbench boundary before audio-bearing lifecycle work. Scenes do not create unlock buttons or touch Howler globals. | +| Presenter commands | Every command passes `isPresenterCommand()`. The control plane consumes sanitized commands; scenes do not install keyboard listeners, direct `BroadcastChannel` listeners, or second command schemas. | +| Lifecycle/cancellation | One activation control plane owns sleep, advance, abort, and teardown. `AbortSignal` means navigation supersession/dispose, not pause/resume. Sleep helpers must subscribe once and unregister; no polling loops. | +| DOM/chrome | Workbench chrome is persistent and workbench-owned. Scene cleanup must wipe only scene-owned DOM/chrome slots for that activation; it must not clear the whole document or remove the chrome surface. | +| GSAP | Scene animation may use `ctx.gsap`. No scene imports GSAP directly, and no runtime master timeline sequences scenes or owns presenter advance. Kill or revert scene-local animations through the activation disposal registry. | +| Audio teardown | Scene audio reaches Howler only through `ctx.audio`. The control plane must fade/stop/unload scene audio on scene exit and stop all navigation audio on supersession. No raw `