From 78338abaf05447d758741ce3e1f3a2e9f584acb3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 08:42:37 +0200 Subject: [PATCH 01/28] Add defineScene authoring normalizer as single source of scene defaulting Authors write { id, title, create, timeline, cleanup }; every other SceneModule field defaults via defineScene, which validates the normalized result against the contract. buildTemplateScene now delegates to it so scene defaults live in exactly one place. --- src/runtime/scene.ts | 53 ++++++++++++++++++++ src/system/templates/_shared.ts | 43 +++++++++------- tests/runtime/scene.test.ts | 89 +++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 18 deletions(-) diff --git a/src/runtime/scene.ts b/src/runtime/scene.ts index 308a0b4..92adf79 100644 --- a/src/runtime/scene.ts +++ b/src/runtime/scene.ts @@ -317,6 +317,59 @@ export function assertSceneModule(value: unknown): asserts value is SceneModule } } +/** + * Author-facing scene input. Only identity (`id`, `title`) and the two + * lifecycle hooks that actually do work (`create`, `timeline`) are + * required; every other field defaults. {@link defineScene} normalizes + * this into the canonical {@link SceneModule} the runtime consumes, so + * authoring a scene is a plain object literal — not a 13-field ritual. + */ +export interface SceneInput { + id: string; + title: string; + duration?: number | null | undefined; + tags?: readonly string[] | undefined; + assets?: readonly string[] | undefined; + captions?: readonly Caption[] | undefined; + audio?: readonly string[] | undefined; + defaultNext?: string | null | undefined; + standalone?: boolean | undefined; + trailerSafe?: boolean | undefined; + create: SceneLifecycleFn; + timeline: SceneLifecycleFn; + /** Defaults to a no-op. Mandatory cleanup (PUL-P001) is satisfied trivially when a scene mounts nothing of its own. */ + cleanup?: SceneLifecycleFn | undefined; +} + +const NOOP_CLEANUP: SceneLifecycleFn = () => {}; + +/** + * The single source of scene defaulting. Fills the optional metadata + * fields, then asserts the result against the {@link SceneModule} + * contract so a malformed input fails loudly at authoring time rather + * than at mount time. Template factories and decks both route through + * here — there is exactly one place defaults live. + */ +export function defineScene(input: SceneInput): SceneModule { + const scene: SceneModule = { + id: input.id, + title: input.title, + duration: input.duration ?? null, + tags: input.tags ?? [], + assets: input.assets ?? [], + captions: input.captions ?? [], + audio: input.audio ?? [], + defaultNext: input.defaultNext ?? null, + standalone: input.standalone ?? false, + trailerSafe: input.trailerSafe ?? false, + create: input.create, + timeline: input.timeline, + cleanup: input.cleanup ?? NOOP_CLEANUP, + }; + assertSceneModule(scene); + return scene; +} + /** * PUL-F030 / ADR-029: predicate the loader/workbench unlock gate, the * validation pass, and future authoring lints all consult so "this diff --git a/src/system/templates/_shared.ts b/src/system/templates/_shared.ts index 8e69a46..de1d19f 100644 --- a/src/system/templates/_shared.ts +++ b/src/system/templates/_shared.ts @@ -11,7 +11,12 @@ // their own DOM use the scene root returned from `mountTemplateRoot`. import { NAVIGATION_MODES } from '../../runtime/navigation'; -import type { Caption, SceneLifecycleFn, SceneModule } from '../../runtime/scene'; +import { + type Caption, + type SceneLifecycleFn, + type SceneModule, + defineScene, +} from '../../runtime/scene'; import type { WorkbenchSceneCtx } from '../../runtime/scene-loader'; import type { ChromeSlots } from '../chrome'; import { addAdvanceGate } from '../helpers/timing'; @@ -315,21 +320,23 @@ export interface BuildTemplateSceneHost { /** * Build a `SceneModule` from the template-specific lifecycle hooks + - * metadata. Provides sensible defaults so each template factory only - * declares what differs. + * metadata. Routes through {@link defineScene} so scene defaulting and + * validation live in exactly one place; only the template-specific + * defaults (a `template` tag, standalone/trailer-safe by default, and + * the standard root-removing cleanup) are applied here. */ -export const buildTemplateScene = (host: BuildTemplateSceneHost): SceneModule => ({ - id: host.id, - title: host.title, - duration: null, - tags: host.tags ?? ['template'], - assets: host.assets ?? [], - captions: host.captions ?? [], - audio: host.audio ?? [], - defaultNext: host.defaultNext ?? null, - standalone: host.standalone ?? true, - trailerSafe: host.trailerSafe ?? true, - create: host.create, - timeline: host.timeline, - cleanup: host.cleanup ?? cleanupTemplateRoot(host.id), -}); +export const buildTemplateScene = (host: BuildTemplateSceneHost): SceneModule => + defineScene({ + id: host.id, + title: host.title, + tags: host.tags ?? ['template'], + assets: host.assets, + captions: host.captions, + audio: host.audio, + defaultNext: host.defaultNext, + standalone: host.standalone ?? true, + trailerSafe: host.trailerSafe ?? true, + create: host.create, + timeline: host.timeline, + cleanup: host.cleanup ?? cleanupTemplateRoot(host.id), + }); diff --git a/tests/runtime/scene.test.ts b/tests/runtime/scene.test.ts index 3589ec8..092b76c 100644 --- a/tests/runtime/scene.test.ts +++ b/tests/runtime/scene.test.ts @@ -3,6 +3,7 @@ import { type Caption, type SceneModule, assertSceneModule, + defineScene, isSceneModule, sceneDeclaresAudio, } from '../../src/runtime/scene'; @@ -562,3 +563,91 @@ describe('SceneModule contract (PUL-F001)', () => { }); }); }); + +describe('defineScene (authoring normalizer)', () => { + it('requires only id, title, create, and timeline', () => { + const scene = defineScene({ + id: 'minimal', + title: 'Minimal', + create: () => undefined, + timeline: () => undefined, + }); + expect(isSceneModule(scene)).toBe(true); + }); + + it('defaults every optional metadata field', () => { + const scene = defineScene({ + id: 'minimal', + title: 'Minimal', + create: () => undefined, + timeline: () => undefined, + }); + expect(scene.duration).toBeNull(); + expect(scene.tags).toEqual([]); + expect(scene.assets).toEqual([]); + expect(scene.captions).toEqual([]); + expect(scene.audio).toEqual([]); + expect(scene.defaultNext).toBeNull(); + expect(scene.standalone).toBe(false); + expect(scene.trailerSafe).toBe(false); + }); + + it('defaults cleanup to a callable no-op (mandatory cleanup, PUL-P001)', () => { + const scene = defineScene({ + id: 'minimal', + title: 'Minimal', + create: () => undefined, + timeline: () => undefined, + }); + expect(typeof scene.cleanup).toBe('function'); + expect(() => scene.cleanup(undefined)).not.toThrow(); + }); + + it('preserves supplied values over defaults', () => { + const captions: Caption[] = [{ at: 0, text: 'hook' }]; + const scene = defineScene({ + id: 'rich', + title: 'Rich', + duration: 4200, + tags: ['act-i'], + assets: ['assets/a.mp3'], + audio: ['assets/a.mp3'], + captions, + defaultNext: 'next-scene', + standalone: true, + trailerSafe: true, + create: () => undefined, + timeline: () => undefined, + }); + expect(scene.duration).toBe(4200); + expect(scene.tags).toEqual(['act-i']); + expect(scene.audio).toEqual(['assets/a.mp3']); + expect(scene.captions).toEqual(captions); + expect(scene.defaultNext).toBe('next-scene'); + expect(scene.standalone).toBe(true); + expect(scene.trailerSafe).toBe(true); + }); + + it('validates the normalized result and throws on a malformed field', () => { + expect(() => + defineScene({ + id: 'Bad Id', + title: 'Bad', + create: () => undefined, + timeline: () => undefined, + }), + ).toThrow(/scene "Bad Id" is invalid: id/); + }); + + it('enforces the audio-subset-of-assets cross-field invariant (PUL-F030)', () => { + expect(() => + defineScene({ + id: 'audio-leak', + title: 'Audio Leak', + audio: ['assets/undeclared.mp3'], + create: () => undefined, + timeline: () => undefined, + }), + ).toThrow(/audio\[0\].*must be a member of scene\.assets/); + }); +}); From 4d5d2d35e45841f76a1bc4cb0e3840c5998f1337 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 08:48:16 +0200 Subject: [PATCH 02/28] Centralize per-mode behavior into a ModeProfile data table Mode becomes data, not control flow: audio policy, slice truncation, chrome visibility, bed suppression, the scrub cue gate, and head-scene runner hints were scattered as mode === X branches across scene-loader and workbench-chrome; they now read one frozen profile per mode. Behavior is unchanged, pinned by a snapshot-equivalence test against the prior scattered logic for all eight modes. Collapsing the four runner-hint ternaries into a single ...runnerHints spread dropped runLifecycle below the cognitive-complexity gate; its suppression and backlog row are removed. --- changelog.d/+mode-profile-table.changed.md | 10 ++ docs/design/complexity-backlog.md | 8 +- src/runtime/mode-profile.ts | 127 +++++++++++++++++++++ src/runtime/scene-loader.ts | 90 ++++----------- src/runtime/workbench-chrome.ts | 14 +-- tests/runtime/mode-profile.test.ts | 70 ++++++++++++ 6 files changed, 238 insertions(+), 81 deletions(-) create mode 100644 changelog.d/+mode-profile-table.changed.md create mode 100644 src/runtime/mode-profile.ts create mode 100644 tests/runtime/mode-profile.test.ts 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/docs/design/complexity-backlog.md b/docs/design/complexity-backlog.md index a175b60..c12635e 100644 --- a/docs/design/complexity-backlog.md +++ b/docs/design/complexity-backlog.md @@ -54,9 +54,15 @@ function per site. | [`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 | +`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. + ### Test fixtures and helpers These three test offenders are isolated functions, not part of the diff --git a/src/runtime/mode-profile.ts b/src/runtime/mode-profile.ts new file mode 100644 index 0000000..7d7ed92 --- /dev/null +++ b/src/runtime/mode-profile.ts @@ -0,0 +1,127 @@ +// Workbench mode profiles — the single data source for per-mode behavior. +// +// Each of the eight workbench modes (ADR-007 / ADR-016..022) decides a +// small, fixed set of runtime behaviors: the audio output policy, whether +// the composition slice truncates to its addressed head, chrome +// visibility, whether the composition audio bed is suppressed, whether the +// loader builds the shared scrub cue gate, and the head-scene runner-input +// hints the resolver receives. +// +// Before this table those decisions were scattered as `mode === X` +// branches across `scene-loader.ts` (audio policy, runner hints, slice +// truncation, bed suppression, cue gate) and `workbench-chrome.ts` (chrome +// visibility). Mode is data, not control flow: the loader and chrome +// surface read a profile instead of re-deriving each behavior. Adding a +// mode means adding one row here — the consumers do not change. + +import type { AudioOutputPolicy } from './audio'; +import type { NavigationMode } from './navigation'; + +/** + * Head-scene runner-input hints. The resolver scopes each hint to the + * addressed head scene only; following composition entries never see them + * because the slice is truncated upstream (single-scene modes) or the + * head's timeline never naturally completes (loop/paused). At most one + * field is set, because the modes that set them are mutually exclusive at + * the URL boundary. + */ +export interface RunnerHints { + /** PUL-F015 / ADR-018: restart the head scene's timeline on completion. */ + readonly repeat?: 'until-aborted'; + /** PUL-F016 / ADR-019: hold the head scene's timeline at the first frame. */ + readonly hold?: 'first-frame'; + /** PUL-F017 / ADR-020: gate audio cues to monotonic forward playback. */ + readonly cueGate?: 'monotonic-forward'; + /** PUL-F018 / ADR-021: render the addressed beat, hold still, suppress audio, seed RNG. */ + readonly screenshot?: 'capture'; +} + +/** The fixed behavior a workbench mode selects. */ +export interface ModeProfile { + /** Audio output policy for the per-navigation audio service (ADR-004). */ + readonly audioPolicy: AudioOutputPolicy; + /** Whether the validated composition slice truncates to its addressed head. */ + readonly singleScene: boolean; + /** Chrome surface visibility absent a composition `behavior.chrome` override (ADR-031). */ + readonly chromeVisibility: 'visible' | 'hidden'; + /** Whether the composition audio bed is suppressed — the scene runs as if standalone (PUL-F014). */ + readonly suppressBed: boolean; + /** Whether the loader builds the shared scrub cue gate for this mode (PUL-F017). */ + readonly buildScrubCueGate: boolean; + /** Head-scene runner-input hints threaded to the resolver (empty for most modes). */ + readonly runnerHints: RunnerHints; +} + +const profile = (p: ModeProfile): ModeProfile => Object.freeze(p); + +const MODE_PROFILES: Readonly> = Object.freeze({ + present: profile({ + audioPolicy: 'audible', + singleScene: false, + chromeVisibility: 'visible', + suppressBed: false, + buildScrubCueGate: false, + runnerHints: Object.freeze({}), + }), + standalone: profile({ + audioPolicy: 'audible', + singleScene: true, + chromeVisibility: 'hidden', + suppressBed: true, + buildScrubCueGate: false, + runnerHints: Object.freeze({}), + }), + loop: profile({ + audioPolicy: 'audible', + singleScene: true, + chromeVisibility: 'visible', + suppressBed: false, + buildScrubCueGate: false, + runnerHints: Object.freeze({ repeat: 'until-aborted' }), + }), + paused: profile({ + audioPolicy: 'silent', + singleScene: true, + chromeVisibility: 'visible', + suppressBed: false, + buildScrubCueGate: false, + runnerHints: Object.freeze({ hold: 'first-frame' }), + }), + scrub: profile({ + audioPolicy: 'audible', + singleScene: true, + chromeVisibility: 'visible', + suppressBed: false, + buildScrubCueGate: true, + runnerHints: Object.freeze({ cueGate: 'monotonic-forward' }), + }), + screenshot: profile({ + audioPolicy: 'silent', + singleScene: true, + chromeVisibility: 'hidden', + suppressBed: false, + buildScrubCueGate: false, + runnerHints: Object.freeze({ screenshot: 'capture' }), + }), + prompter: profile({ + audioPolicy: 'audible', + singleScene: false, + chromeVisibility: 'visible', + suppressBed: false, + buildScrubCueGate: false, + runnerHints: Object.freeze({}), + }), + rehearsal: profile({ + audioPolicy: 'log-cues', + singleScene: false, + chromeVisibility: 'visible', + suppressBed: false, + buildScrubCueGate: false, + runnerHints: Object.freeze({}), + }), +}); + +/** The frozen profile for a workbench mode. */ +export function profileFor(mode: NavigationMode): ModeProfile { + return MODE_PROFILES[mode]; +} diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index 3c0db08..81f68db 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -49,6 +49,7 @@ import type { } from './composition-resolver'; import { describeErrorDetailed, formatSceneContext } from './error'; import { KEBAB_IDENTIFIER_FORM, isKebabIdentifier } from './identifier'; +import { profileFor } from './mode-profile'; import { NAVIGATION_MODES, type NavigationLocator, @@ -658,9 +659,7 @@ function countSceneOccurrences(target: SceneNavigationTarget): Map => loadSceneNavigationTarget(resolved, { ctx: buildSceneCtx, @@ -1473,13 +1435,12 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { signal: controller.signal, ...(beat === undefined ? {} : { beat }), ...(onBeatMissing === undefined ? {} : { onBeatMissing }), - ...(repeat === undefined ? {} : { repeat }), - ...(hold === undefined ? {} : { hold }), - ...(cueGate === undefined ? {} : { cueGate }), + // Head-scene runner hints (repeat / hold / cueGate / screenshot) + // from the mode profile; an empty object for modes that set none. + ...runnerHints, // PUL-F017 / ADR-020: the same gate handed to the audio service // above — the timeline adapter toggles it by playhead direction. ...(audioCueGate === undefined ? {} : { audioCueGate }), - ...(screenshot === undefined ? {} : { screenshot }), // Forward `presenter` AND the `onError` sink so the // resolver's per-scene wrapper around `presenter` can // route runner-handler exceptions / unknown-kind drops @@ -1606,14 +1567,7 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { target: NavigationTarget, ): SceneNavigationTarget => { const mode = effectiveMode(target); - if ( - (mode !== 'standalone' && - mode !== 'loop' && - mode !== 'paused' && - mode !== 'scrub' && - mode !== 'screenshot') || - resolved.composition === undefined - ) { + if (!profileFor(mode).singleScene || resolved.composition === undefined) { return resolved; } const headEntry = resolved.composition.manifestSlice[0]; diff --git a/src/runtime/workbench-chrome.ts b/src/runtime/workbench-chrome.ts index a2e32e5..f3699c4 100644 --- a/src/runtime/workbench-chrome.ts +++ b/src/runtime/workbench-chrome.ts @@ -26,6 +26,7 @@ // removal — testable against fakes while production `main.ts` supplies // real `document.createElement` and the live mount point. +import { profileFor } from './mode-profile'; import { NAVIGATION_MODES, type NavigationMode } from './navigation'; /** @@ -131,18 +132,7 @@ export interface WorkbenchChromeController { * the resolver. */ export function chromeVisibilityFor(mode: NavigationMode): 'visible' | 'hidden' { - switch (mode) { - case 'standalone': - case 'screenshot': - return 'hidden'; - case 'present': - case 'loop': - case 'paused': - case 'scrub': - case 'prompter': - case 'rehearsal': - return 'visible'; - } + return profileFor(mode).chromeVisibility; } const VISIBILITY_ATTR = 'data-pulsar-chrome-visibility'; diff --git a/tests/runtime/mode-profile.test.ts b/tests/runtime/mode-profile.test.ts new file mode 100644 index 0000000..26ab029 --- /dev/null +++ b/tests/runtime/mode-profile.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { type ModeProfile, profileFor } from '../../src/runtime/mode-profile'; +import { NAVIGATION_MODES, type NavigationMode } from '../../src/runtime/navigation'; + +// Snapshot-equivalence guard for the ModeProfile data table. +// +// Before the table, the per-mode behaviors lived as scattered +// `mode === X` branches in scene-loader.ts and workbench-chrome.ts. +// These reference implementations reproduce that prior logic verbatim; +// the table MUST agree with them for every mode, so this test is the +// proof the centralization changed no behavior. + +const expectedAudioPolicy = (mode: NavigationMode): ModeProfile['audioPolicy'] => { + if (mode === 'rehearsal') return 'log-cues'; + if (mode === 'screenshot' || mode === 'paused') return 'silent'; + return 'audible'; +}; + +const expectedChromeVisibility = (mode: NavigationMode): ModeProfile['chromeVisibility'] => + mode === 'standalone' || mode === 'screenshot' ? 'hidden' : 'visible'; + +const expectedSingleScene = (mode: NavigationMode): boolean => + mode === 'standalone' || + mode === 'loop' || + mode === 'paused' || + mode === 'scrub' || + mode === 'screenshot'; + +const expectedSuppressBed = (mode: NavigationMode): boolean => mode === 'standalone'; + +const expectedBuildScrubCueGate = (mode: NavigationMode): boolean => mode === 'scrub'; + +const expectedRunnerHints = (mode: NavigationMode): ModeProfile['runnerHints'] => { + if (mode === 'loop') return { repeat: 'until-aborted' }; + if (mode === 'paused') return { hold: 'first-frame' }; + if (mode === 'scrub') return { cueGate: 'monotonic-forward' }; + if (mode === 'screenshot') return { screenshot: 'capture' }; + return {}; +}; + +describe('mode profile table', () => { + it('defines a profile for every navigation mode', () => { + for (const mode of NAVIGATION_MODES) { + expect(profileFor(mode), `missing profile for "${mode}"`).toBeDefined(); + } + }); + + it.each(NAVIGATION_MODES)('profile for "%s" matches the prior scattered logic', (mode) => { + const p = profileFor(mode); + expect(p.audioPolicy).toBe(expectedAudioPolicy(mode)); + expect(p.chromeVisibility).toBe(expectedChromeVisibility(mode)); + expect(p.singleScene).toBe(expectedSingleScene(mode)); + expect(p.suppressBed).toBe(expectedSuppressBed(mode)); + expect(p.buildScrubCueGate).toBe(expectedBuildScrubCueGate(mode)); + expect(p.runnerHints).toEqual(expectedRunnerHints(mode)); + }); + + it('at most one runner hint is set per mode (modes are mutually exclusive at the URL boundary)', () => { + for (const mode of NAVIGATION_MODES) { + const set = Object.values(profileFor(mode).runnerHints).filter((v) => v !== undefined); + expect(set.length, `"${mode}" sets more than one runner hint`).toBeLessThanOrEqual(1); + } + }); + + it('freezes profiles so a consumer cannot mutate the shared table', () => { + const p = profileFor('loop'); + expect(Object.isFrozen(p)).toBe(true); + expect(Object.isFrozen(p.runnerHints)).toBe(true); + }); +}); From d22bbb7dcaa9094f07ffb47bf6c5c067c39c191f Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 09:15:36 +0200 Subject: [PATCH 03/28] refactor(scene-loader): decompose buildLoad/runTarget below the complexity gate Split the scene-loader god-functions into cohesive single-responsibility units so both drop under the cognitive-complexity gate and their biome-ignore suppressions are deleted: - src/runtime/scene-loader-ctx.ts: per-navigation audio service, presenter pipe (PUL-F025 master-mute handler), deterministic RNG seed, and the per-occurrence ctx factory (the old buildLoad try/catch body). - src/runtime/scene-loader-guard.ts: the present-mode audio unlock-gate predicate (PUL-F030) and the composition chrome dispatch policy (PUL-F031), as the navigation trust seams. - src/runtime/navigation.ts: a single NAVIGATION_GRAMMAR rule source for the beat/mode grammar, consumed by parseNavigationSearch AND the loader's defense-in-depth validateBeatGrammar/validateModeGrammar re-check (now exported from navigation). The duplicated rule strings are eliminated; the forged-target trust seam is retained. runTarget now delegates to dispatchLifecycleLoad + awaitLoad and folds its two grammar checks into one. buildLoad delegates services construction and run-input assembly, keeping the abort/queue/cleanup- exactly-once lifecycle and stage-attr ordering intact. createSceneLoader, all exported types, the data-pulsar-* attributes, and runtime behavior are unchanged. Both noExcessiveCognitiveComplexity suppressions and their complexity-backlog rows are removed. --- .../+scene-loader-decomposition.changed.md | 12 + docs/design/complexity-backlog.md | 13 +- src/runtime/navigation.ts | 114 ++- src/runtime/scene-loader-ctx.ts | 296 ++++++ src/runtime/scene-loader-guard.ts | 134 +++ src/runtime/scene-loader.ts | 962 +++++------------- .../policy-biome-complexity-gate.test.ts | 1 - 7 files changed, 784 insertions(+), 748 deletions(-) create mode 100644 changelog.d/+scene-loader-decomposition.changed.md create mode 100644 src/runtime/scene-loader-ctx.ts create mode 100644 src/runtime/scene-loader-guard.ts 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/docs/design/complexity-backlog.md b/docs/design/complexity-backlog.md index c12635e..da402a8 100644 --- a/docs/design/complexity-backlog.md +++ b/docs/design/complexity-backlog.md @@ -53,8 +53,6 @@ function per site. | [`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) | `runTarget` async arrow | 23 | `runLifecycle` (formerly score 21) was removed from this list when the per-mode runner hints (`repeat` / `hold` / `cueGate` / `screenshot`) @@ -63,6 +61,17 @@ collapsed from four `mode === X` ternaries into a single [`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 diff --git a/src/runtime/navigation.ts b/src/runtime/navigation.ts index b6ac4e1..8c330db 100644 --- a/src/runtime/navigation.ts +++ b/src/runtime/navigation.ts @@ -106,6 +106,47 @@ export interface NavigationTarget { const GRAMMAR_KEYS = ['scene', 'composition', 'index', 'beat', 'mode'] as const; +/** Stable prefix every grammar diagnostic carries (see {@link fail}). */ +export const NAVIGATION_GRAMMAR_PREFIX = 'navigation grammar is invalid:'; + +/** + * The locator kinds a `beat` may pair with (ADR-013): a beat positions + * a scene timeline, so the target must address a single scene. + */ +function isSceneLikeLocator(locator: NavigationLocator): boolean { + return ( + locator.kind === 'scene' || + locator.kind === 'composition-scene' || + locator.kind === 'composition-index' + ); +} + +/** + * The single source of truth for ADR-013's `beat` / `mode` grammar + * rules. Both {@link parseNavigationSearch} (URL boundary) and the + * scene loader's defense-in-depth re-check (programmatic / forged + * `NavigationTarget`s) consume these predicates + condition messages + * so the rules — and their exact diagnostic wording — live in one + * place. The condition strings are the suffix after + * {@link NAVIGATION_GRAMMAR_PREFIX}; consumers prepend the prefix. + */ +export const NAVIGATION_GRAMMAR = Object.freeze({ + beatKebab: Object.freeze({ + valid: (beat: string): boolean => isKebabIdentifier(beat), + condition: `"beat" must be a non-empty lowercase kebab-case string (${KEBAB_IDENTIFIER_FORM})`, + }), + beatSceneLike: Object.freeze({ + valid: isSceneLikeLocator, + condition: + '"beat" requires a scene-like target ("scene", "composition" + "scene", or "composition" + "index")', + }), + mode: Object.freeze({ + valid: (mode: string): boolean => (NAVIGATION_MODES as readonly string[]).includes(mode), + condition: (mode: string): string => + `"mode" unknown mode "${mode}" — allowed: ${NAVIGATION_MODES.join(', ')}`, + }), +}); + // ADR-013 defines `index` as "base-10, zero-based, non-negative safe // integer." The pattern enforces only the base-10 digit set; leading // zeros (e.g. `01`) are accepted because the ADR does not forbid @@ -120,7 +161,7 @@ const INDEX_PATTERN = /^\d+$/; * field-specific detail. */ function fail(condition: string): never { - throw new Error(`navigation grammar is invalid: ${condition}`); + throw new Error(`${NAVIGATION_GRAMMAR_PREFIX} ${condition}`); } function toSearchParams(input: URLSearchParams | string): URLSearchParams { @@ -146,6 +187,11 @@ function validateKebab(field: string, value: string): void { } } +// `beat` is the only kebab field whose diagnostic is shared verbatim +// with the loader's defense-in-depth re-check, so its rule lives in +// `NAVIGATION_GRAMMAR`; `scene` / `composition` reuse `validateKebab` +// (same wording, parser-only) to avoid widening the shared source. + function parseIndex(raw: string): number { if (!INDEX_PATTERN.test(raw)) { fail('"index" must be a base-10 non-negative integer (e.g. 0, 1, 12)'); @@ -162,12 +208,12 @@ function parseIndex(raw: string): number { } function parseMode(raw: string): NavigationMode { - // The membership check is correct as written; the type assertion - // documents that callers receive a typed mode, not a bare string. - if ((NAVIGATION_MODES as readonly string[]).includes(raw)) { + // The membership check is the shared `NAVIGATION_GRAMMAR.mode` rule; + // the type assertion documents that callers receive a typed mode. + if (NAVIGATION_GRAMMAR.mode.valid(raw)) { return raw as NavigationMode; } - fail(`"mode" unknown mode "${raw}" — allowed: ${NAVIGATION_MODES.join(', ')}`); + fail(NAVIGATION_GRAMMAR.mode.condition(raw)); } function buildLocator( @@ -202,16 +248,8 @@ function buildLocator( function ensureBeatHasSceneLikeTarget(beat: string | undefined, locator: NavigationLocator): void { if (beat === undefined) return; - if ( - locator.kind === 'scene' || - locator.kind === 'composition-scene' || - locator.kind === 'composition-index' - ) { - return; - } - fail( - '"beat" requires a scene-like target ("scene", "composition" + "scene", or "composition" + "index")', - ); + if (NAVIGATION_GRAMMAR.beatSceneLike.valid(locator)) return; + fail(NAVIGATION_GRAMMAR.beatSceneLike.condition); } /** @@ -263,6 +301,52 @@ export function parseNavigationSearch(input: URLSearchParams | string): Navigati return Object.freeze(target); } +/** + * Build a grammar `Error` (prefixed with {@link NAVIGATION_GRAMMAR_PREFIX}) + * from a condition string, so the loader's defense-in-depth re-check + * produces byte-identical diagnostics to {@link parseNavigationSearch}. + */ +function grammarError(condition: string): Error { + return new Error(`${NAVIGATION_GRAMMAR_PREFIX} ${condition}`); +} + +/** + * Defense-in-depth for ADR-013's `beat` grammar (PUL-F011). + * {@link parseNavigationSearch} enforces these rules on URL input, but + * `NavigationTarget` is an exported type that non-parser callers + * (event-detail unmarshaling, programmatic navigation, test harnesses) + * can construct directly. Re-checking at the loader boundary stops a + * hand-built target with an invalid `beat` from reaching the runner. + * Returns an `Error` carrying the parser's exact grammar message, or + * `null` when no further check is needed. Consumes {@link NAVIGATION_GRAMMAR} + * so the rule lives in one place yet the trust seam is preserved. + */ +export function validateBeatGrammar(target: NavigationTarget): Error | null { + if (target.beat === undefined) return null; + if (!NAVIGATION_GRAMMAR.beatKebab.valid(target.beat)) { + return grammarError(NAVIGATION_GRAMMAR.beatKebab.condition); + } + if (!NAVIGATION_GRAMMAR.beatSceneLike.valid(target.locator)) { + return grammarError(NAVIGATION_GRAMMAR.beatSceneLike.condition); + } + return null; +} + +/** + * Defense-in-depth for ADR-007's mode allowlist (PUL-F012). Symmetric + * to {@link validateBeatGrammar}: re-checks a programmatically-built + * `target.mode` against {@link NAVIGATION_GRAMMAR} before it reaches + * {@link effectiveMode} and the scene ctx. Returns an `Error` with the + * parser's exact message, or `null` when no further check is needed. + */ +export function validateModeGrammar(target: NavigationTarget): Error | null { + if (target.mode === undefined) return null; + if (!NAVIGATION_GRAMMAR.mode.valid(target.mode)) { + return grammarError(NAVIGATION_GRAMMAR.mode.condition(target.mode)); + } + return null; +} + /** * Derive the effective workbench mode for the runtime/core dispatch * boundary (PUL-F012). When `target.mode` is present, that mode is diff --git a/src/runtime/scene-loader-ctx.ts b/src/runtime/scene-loader-ctx.ts new file mode 100644 index 0000000..2f0d718 --- /dev/null +++ b/src/runtime/scene-loader-ctx.ts @@ -0,0 +1,296 @@ +// Scene-loader per-navigation context construction — PUL-F008. +// +// `scene-loader.ts` orchestrates the navigation queue, abort lifecycle, +// and stage-attr envelope. This module owns the cohesive sub-concern it +// delegates to: building the per-navigation audio service, presenter +// pipe, deterministic RNG seed, and the per-occurrence `ctx` factory — +// the work `createSceneLoader.buildLoad` used to inline. Pulling it here +// keeps `buildLoad` within the cognitive-complexity budget and gives the +// ctx-assembly seam a single home. +// +// References: +// - PUL-F018 / ADR-021 — deterministic per-navigation RNG seed. +// - PUL-F024 / PUL-F026 / ADR-004 — per-navigation audio service. +// - PUL-F025 / ADR-023 — presenter pipe + master-mute audio handler. +// - issue #99 — per-occurrence activation identity + RNG stream. + +import type { AssetUrlPolicy } from './asset-preloader'; +import { + type AudioCueLogEntry, + type AudioEngine, + type AudioOutputPolicy, + type AudioService, + type AudioServiceOptions, + type CueGateControl, + createAudioService, +} from './audio'; +import type { SceneActivation } from './composition-resolver'; +import { profileFor } from './mode-profile'; +import type { NavigationLocator, NavigationMode, NavigationTarget } from './navigation'; +import { + type PresenterCommandSource, + type PresenterController, + createPresenterController, +} from './presenter'; +import { createSeededRng } from './rng'; +import type { SceneNavigationTarget } from './scene-navigation'; +import { PULSAR_RUNTIME_VERSION } from './version'; + +/** + * The audio source URLs the resolved (possibly head-truncated) slice + * declared as audio — every scene's static `SceneModule.audio` list in + * the slice (just the head scene's for a bare `kind: 'scene'` target, + * the full composition slice's for composition targets). The + * per-navigation audio service uses this as the SCENE-FACING source + * allowlist so `ctx.audio.load()` can only register URLs the scene + * EXPLICITLY declared as audio — not any URL that happens to be in + * `scene.assets`. This keeps the PUL-F030 / ADR-029 unlock-gate + * predicate and the audio-service allowlist consistent, so the + * present-mode unlock gate cannot be bypassed by a scene that hides + * its audio in `assets`. + * + * The PUL-F014 composition audio bed is deliberately NOT added here: + * the bed is composition-owned runtime infrastructure gated against its + * OWN declared `src` inside `startBed`, so exposing it through the scene + * allowlist would let a scene replay the bed as its own sound. + */ +function collectAudioSources(target: SceneNavigationTarget): readonly string[] { + return target.composition === undefined + ? target.scene.audio + : target.composition.sceneSlice.flatMap((scene) => scene.audio); +} + +/** + * Count, per scene id, how many times it occurs in the resolved + * navigation slice (issue #99). The loader's occurrence-safe audio + * teardown consults this so the scene-scoped audio group (shared by + * every occurrence of that scene because `ctx.audio` is one + * slice-scoped service) is stopped exactly once — when the LAST + * occurrence of that id has been cleaned up — rather than once per + * occurrence. For a single-occurrence scene the count is `1`. + */ +export function countSceneOccurrences(target: SceneNavigationTarget): Map { + const scenes = target.composition === undefined ? [target.scene] : target.composition.sceneSlice; + const counts = new Map(); + for (const scene of scenes) { + counts.set(scene.id, (counts.get(scene.id) ?? 0) + 1); + } + return counts; +} + +/** + * Map the effective workbench mode to the per-navigation + * {@link AudioOutputPolicy} (PUL-F024 / PUL-F026 / ADR-004). Sourced + * from the mode profile so the mode→policy table lives in one place. + */ +function audioOutputPolicyFor(mode: NavigationMode): AudioOutputPolicy { + return profileFor(mode).audioPolicy; +} + +/** + * Serialize a {@link NavigationLocator} into a stable, collision-free + * string. Every locator kind contributes its discriminant plus its + * identifier fields, so two distinct addressed targets never fold to + * the same string. Pure helper for {@link deriveNavigationSeed}. + */ +function serializeLocator(locator: NavigationLocator): string { + switch (locator.kind) { + case 'none': + return 'none'; + case 'scene': + return `scene:${locator.scene}`; + case 'composition': + return `composition:${locator.composition}`; + case 'composition-scene': + return `composition:${locator.composition}/scene:${locator.scene}`; + case 'composition-index': + return `composition:${locator.composition}/index:${locator.index}`; + } +} + +/** + * PUL-F018 / ADR-021: derive the per-navigation deterministic seed + * string for the scene-context RNG ({@link import('./scene-loader').WorkbenchSceneCtx.rng}). + * + * The seed is built only from bounded, deterministic inputs the + * workbench URL already carries: the normalized navigation locator, the + * addressed beat, and `PULSAR_RUNTIME_VERSION` so a code revision + * changes the seed. It deliberately does NOT read the wall clock, + * storage, cookies, `history.state`, process state, or the workbench + * `mode` (the addressed frame is mode-independent). Under + * `mode=screenshot` this makes the captured frame reproducible across + * reloads. Pure function, exported so the per-navigation seam is + * unit-testable in isolation. + */ +export function deriveNavigationSeed(target: NavigationTarget): string { + return [ + 'pulsar-rng', + serializeLocator(target.locator), + `beat:${target.beat ?? ''}`, + `v:${PULSAR_RUNTIME_VERSION}`, + ].join('|'); +} + +/** + * PUL-F018 / ADR-021: combine the per-navigation seed from + * {@link deriveNavigationSeed} with one scene occurrence's identity + * ({@link SceneActivation}) so every occurrence gets its OWN seeded + * generator. A composition slice that repeats a scene id (issue #99) + * therefore hands each occurrence an independent random stream. + */ +function deriveActivationSeed(navigationSeed: string, activation: SceneActivation): string { + return `${navigationSeed}|occ:${activation.sceneId}#${activation.entryIndex}.${activation.occurrence}`; +} + +/** A bound scene-ctx base, omitting the per-occurrence fields. */ +type BaseCtx = unknown; + +/** The per-occurrence ctx factory the resolver lifecycle consumes. */ +export type SceneCtxFactory = (activation: SceneActivation) => unknown; + +/** + * The per-navigation services the loader threads into the resolver + * lifecycle: the audio service (for happy-path `stopAll()` + per-scene + * group teardown), the presenter controller (`mode=present` only), the + * presenter teardown signal, and the per-occurrence ctx factory. + */ +export interface NavigationServices { + readonly audio: AudioService; + readonly presenter: PresenterController | undefined; + readonly presenterAbort: AbortController | null; + readonly buildSceneCtx: SceneCtxFactory; +} + +/** Dependencies {@link buildNavigationServices} captures from the loader. */ +export interface NavigationServicesDeps { + readonly audioEngine: AudioEngine; + readonly assetPolicy?: AssetUrlPolicy; + readonly onAudioCue?: (entry: AudioCueLogEntry) => void; + readonly presenterCommands?: PresenterCommandSource; + readonly onError: (err: unknown) => void; + readonly buildCtx: ( + mode: NavigationMode, + audio: AudioService, + presenter?: PresenterController, + ) => BaseCtx; +} + +/** + * Construct the per-navigation presenter pipeline: + * - `presenterAbort` — a separate `AbortController` whose signal drives + * the `PresenterController`'s teardown. Distinct from the navigation + * `controller` so the navigation signal can stay un-aborted across a + * successful completion (PUL-F013 boundary). Wired to fire on + * navigation abort AND aborted by the loader's `finally` on normal + * completion. + * - `presenter` — the `PresenterController` itself (PUL-F020 / ADR-023), + * with the PUL-F025 audio handler (presenter master mute / ADR-004) + * attached. This is the only seam where both the controller and the + * per-navigation `AudioService` are in scope. + * + * Both are `null` / `undefined` when the loader does NOT build a + * controller for this navigation: non-present mode OR the workbench did + * not supply a `presenterCommands` source. + */ +function buildPresenterPipe( + deps: NavigationServicesDeps, + mode: NavigationMode, + controller: AbortController, + audio: AudioService, +): { presenter: PresenterController | undefined; presenterAbort: AbortController | null } { + if (mode !== 'present' || deps.presenterCommands === undefined) { + return { presenter: undefined, presenterAbort: null }; + } + const presenterAbort = new AbortController(); + // Propagate navigation abort → presenter abort. Without this wiring, + // supersession-time `controller.abort()` would not tear down the + // presenter controller (bound to `presenterAbort.signal`). + if (controller.signal.aborted) { + presenterAbort.abort(); + } else { + controller.signal.addEventListener('abort', () => presenterAbort.abort(), { once: true }); + } + const presenter = createPresenterController( + deps.presenterCommands, + presenterAbort.signal, + deps.onError, + ); + // PUL-F025 / ADR-004 audio handler. On `'toggle-master-mute'` flip the + // engine's current master mute via the audio boundary; `audio.mute()` + // validates the boolean and is inert post-dispose. + presenter.subscribe((cmd) => { + if (cmd.kind !== 'toggle-master-mute') return; + audio.mute(!audio.isMuted()); + }); + return { presenter, presenterAbort }; +} + +/** + * Build the audio-service options for the per-navigation + * {@link AudioService}. Split out so the conditional-spread density does + * not charge {@link buildNavigationServices}'s cognitive complexity. + */ +function audioServiceOptions( + deps: NavigationServicesDeps, + resolved: SceneNavigationTarget, + signal: AbortSignal, + outputPolicy: AudioOutputPolicy, + suppressBed: boolean, + audioCueGate: CueGateControl | undefined, +): AudioServiceOptions { + return { + signal, + outputPolicy, + allowedSources: collectAudioSources(resolved), + ...(deps.assetPolicy === undefined ? {} : { assetPolicy: deps.assetPolicy }), + onError: deps.onError, + ...(deps.onAudioCue === undefined ? {} : { onCue: deps.onAudioCue }), + // PUL-F014 / ADR-004: the composition audio bed, suppressed under + // `mode=standalone` (the loader is the mode-dispatch point that + // decides bed vs no-bed, keeping the resolver mode-opaque). + ...(resolved.composition?.audioBed === undefined ? {} : { bed: resolved.composition.audioBed }), + bedSuppressed: suppressBed, + // PUL-F017 / ADR-020: the shared cue gate (scrub only). The audio + // service consults it; the timeline adapter toggles it. + ...(audioCueGate === undefined ? {} : { cueGate: audioCueGate }), + }; +} + +/** + * Build the per-navigation audio service, presenter pipe, and the + * per-occurrence ctx factory (PUL-F024 / PUL-F025 / PUL-F018). The + * audio service is built FIRST so the presenter pipe's mute handler can + * close over it; the ctx factory then layers each occurrence's + * `activation` + seeded `rng` onto the navigation-scoped `buildCtx` + * base. Throws on a failing `createAudioService` / `buildCtx`; the + * caller aborts the controller and rolls back stage attrs. + */ +export function buildNavigationServices( + deps: NavigationServicesDeps, + resolved: SceneNavigationTarget, + target: NavigationTarget, + mode: NavigationMode, + controller: AbortController, + audioCueGate: CueGateControl | undefined, +): NavigationServices { + const audio = createAudioService( + deps.audioEngine, + audioServiceOptions( + deps, + resolved, + controller.signal, + audioOutputPolicyFor(mode), + profileFor(mode).suppressBed, + audioCueGate, + ), + ); + const { presenter, presenterAbort } = buildPresenterPipe(deps, mode, controller, audio); + const baseCtx = deps.buildCtx(mode, audio, presenter); + const navigationSeed = deriveNavigationSeed(target); + const buildSceneCtx: SceneCtxFactory = (activation) => ({ + ...(baseCtx as object), + activation, + rng: createSeededRng(deriveActivationSeed(navigationSeed, activation)), + }); + return { audio, presenter, presenterAbort, buildSceneCtx }; +} diff --git a/src/runtime/scene-loader-guard.ts b/src/runtime/scene-loader-guard.ts new file mode 100644 index 0000000..cb4edb1 --- /dev/null +++ b/src/runtime/scene-loader-guard.ts @@ -0,0 +1,134 @@ +// Scene-loader navigation guards — PUL-F008. +// +// Pure / near-pure decision helpers `createSceneLoader` delegates to so +// `runTarget` and `enqueue` stay within the cognitive-complexity budget: +// - the present-mode audio unlock-gate predicate + builder (PUL-F030); +// - the composition-scoped chrome dispatch policy (PUL-F031). +// +// These are the navigation "trust seams" — they re-derive their inputs +// from the resolved target rather than trusting cached state. +// +// References: +// - PUL-F030 / ADR-029 — present-mode audio unlock gate. +// - PUL-F031 / ADR-031 — composition-scoped chrome dispatch. + +import type { CompositionRegistry } from './composition-registry'; +import { type NavigationMode, effectiveMode } from './navigation'; +import type { NavigationTarget } from './navigation'; +import type { SceneRegistry } from './registry'; +import { sceneDeclaresAudio } from './scene'; +import { type SceneNavigationTarget, resolveSceneNavigation } from './scene-navigation'; + +/** + * PUL-F030 / ADR-029: the internal gate-prelude callback the loader + * awaits BEFORE the resolver lifecycle. The public-facing + * `AudioUnlockAdapter` receives the semantic composition context; this + * helper closes over that context, leaving only the navigation-bound + * `signal` + engine-bound `unlock` supplied at gate-invocation time. + */ +export type UnlockGate = (env: { + readonly signal: AbortSignal; + readonly unlock: () => Promise; +}) => Promise; + +/** + * PUL-F030 / ADR-029: the workbench-supplied unlock adapter's bounded + * semantic context. Declared here (and re-exported from `scene-loader.ts`) + * so the gate predicate and the adapter signature share one definition. + */ +export interface AudioUnlockContext { + readonly compositionId: string; + readonly sceneIds: readonly string[]; + readonly signal: AbortSignal; + readonly unlock: () => Promise; +} + +/** PUL-F030 / ADR-029: signature of the workbench-supplied unlock adapter. */ +export type AudioUnlockAdapter = (gate: AudioUnlockContext) => Promise; + +/** + * PUL-F030 / ADR-029: decide whether the present-mode audio unlock gate + * applies to this navigation, and if so build it. Returns: + * - `null` — the gate does not apply (mode is not present, no + * composition slice, or no scene in the slice declares audio). + * - `'fail-loud'` — the gate applies but no adapter is supplied + * (workbench-bootstrap defect; the gate IS the structural defense). + * - `UnlockGate` — a closure that invokes the adapter with the + * bounded composition context (id, scene ids, signal, unlock); the + * lifecycle awaits it before preload / `create` / `timeline` / + * master playback. The adapter receives no scene objects, source + * URLs, asset payloads, or Howler handles (ADR-029 guardrail). + */ +export function resolveUnlockGate( + adapter: AudioUnlockAdapter | undefined, + target: NavigationTarget, + resolved: SceneNavigationTarget, +): UnlockGate | 'fail-loud' | null { + if (effectiveMode(target) !== 'present') return null; + const composition = resolved.composition; + if (composition === undefined) return null; + if (!composition.sceneSlice.some(sceneDeclaresAudio)) return null; + if (adapter === undefined) return 'fail-loud'; + const compositionId = composition.id; + const sceneIds = Object.freeze(composition.sceneSlice.map((scene) => scene.id)); + return ({ signal, unlock }) => adapter({ compositionId, sceneIds, signal, unlock }); +} + +/** + * PUL-F031 / ADR-031: workbench-supplied chrome controller surface. The + * loader only needs to apply a validated mode + composition overrides. + */ +export interface WorkbenchChromeAdapter { + applyMode(mode: NavigationMode): void; + setForcedVisibility?(visibility: 'hidden' | null): void; + setAtmosphere?(atmosphere: 'cinematic' | null): void; +} + +/** The registries {@link applyChromeForTarget} resolves the chrome policy against. */ +export interface ChromeResolveDeps { + readonly scenes: SceneRegistry; + readonly compositions: CompositionRegistry; +} + +function chromeBehaviorFromResolved(resolved: SceneNavigationTarget | null): unknown { + const head = resolved?.composition?.manifestSlice[0]; + if (head === null || typeof head !== 'object') return undefined; + return (head as { readonly behavior?: { readonly chrome?: unknown } }).behavior?.chrome; +} + +function chromeBehaviorForTarget(deps: ChromeResolveDeps, target: NavigationTarget): unknown { + try { + return chromeBehaviorFromResolved( + resolveSceneNavigation(target, { scenes: deps.scenes, compositions: deps.compositions }), + ); + } catch { + return undefined; + } +} + +/** + * Composition-level chrome policy: the head manifest entry's + * `behavior.chrome` can force visibility hidden or opt into cinematic + * atmosphere. The default is explicit reset (`forcedVisibility=null`, + * `atmosphere=null`) so a navigation away from an atmospheric deck + * clears that state immediately, before serialized scene cleanup. The + * forced-visibility application precedes `applyMode` so chrome hides + * before any cleanup drains (codex review, cycle 1). + */ +export function applyChromeForTarget( + chrome: WorkbenchChromeAdapter, + deps: ChromeResolveDeps, + target: NavigationTarget, +): void { + const chromeBehavior = chromeBehaviorForTarget(deps, target); + const forcedVisibility = chromeBehavior === 'hidden' ? 'hidden' : null; + const atmosphere = + typeof chromeBehavior === 'object' && + chromeBehavior !== null && + (chromeBehavior as { readonly atmosphere?: unknown }).atmosphere === 'cinematic' + ? 'cinematic' + : null; + chrome.setForcedVisibility?.(forcedVisibility); + chrome.setAtmosphere?.(atmosphere); + chrome.applyMode(effectiveMode(target)); +} diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index 81f68db..5661c76 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -33,10 +33,8 @@ import type { AssetUrlPolicy } from './asset-preloader'; import { type AudioCueLogEntry, type AudioEngine, - type AudioOutputPolicy, type AudioService, type CueGateControl, - createAudioService, createCueGate, noopAudioEngine, } from './audio'; @@ -48,14 +46,13 @@ import type { SceneFailureEvent, } from './composition-resolver'; import { describeErrorDetailed, formatSceneContext } from './error'; -import { KEBAB_IDENTIFIER_FORM, isKebabIdentifier } from './identifier'; import { profileFor } from './mode-profile'; import { - NAVIGATION_MODES, - type NavigationLocator, type NavigationMode, type NavigationTarget, effectiveMode, + validateBeatGrammar, + validateModeGrammar, } from './navigation'; import { type PresenterCommandSource, @@ -69,15 +66,25 @@ import { buildPrompterScript, } from './prompter'; import type { SceneRegistry } from './registry'; -import { createSeededRng } from './rng'; -import { sceneDeclaresAudio } from './scene'; +import { + buildNavigationServices, + countSceneOccurrences, + deriveNavigationSeed, +} from './scene-loader-ctx'; +import { + type AudioUnlockAdapter, + type AudioUnlockContext, + type UnlockGate, + type WorkbenchChromeAdapter, + applyChromeForTarget, + resolveUnlockGate, +} from './scene-loader-guard'; import { type SceneNavigationTarget, loadSceneNavigationTarget, resolveSceneNavigation, } from './scene-navigation'; import { type TimelineEngine, sceneSegmentLabel } from './timeline'; -import { PULSAR_RUNTIME_VERSION } from './version'; /** The minimal subset of an HTMLElement the loader writes to. */ export interface StageElement { @@ -451,75 +458,11 @@ export interface SceneLoaderOptions { readonly chrome?: WorkbenchChromeAdapter; } -/** - * PUL-F031 / ADR-031: workbench-supplied chrome controller surface. - * The loader only needs the one operation — apply a validated mode - * to chrome — so the interface stays narrow. The full chrome - * controller lives in {@link import('./workbench-chrome').WorkbenchChromeController} - * with a `dispose()` method the workbench (not the loader) calls on - * HMR teardown. - */ -export interface WorkbenchChromeAdapter { - applyMode(mode: NavigationMode): void; - /** - * Optional composition-level override: force chrome visibility hidden - * regardless of the mode-derived default. Used when a composition's - * head entry declares `behavior.chrome: 'hidden'` (e.g. a deck that - * owns its own atmospherics). Pass `null` to clear the override. - * Optional on the adapter so existing chrome implementations stay - * compatible — the loader only calls it when defined. - */ - setForcedVisibility?(visibility: 'hidden' | null): void; - /** - * Optional composition-scoped atmospheric layer switch. The global - * workbench default is no animated atmospheric background; decks that - * were designed for it opt in from the composition manifest. - */ - setAtmosphere?(atmosphere: 'cinematic' | null): void; -} - -/** - * PUL-F030 / ADR-029: semantic context handed to the workbench-supplied - * unlock adapter. The adapter MUST NOT receive raw scene objects, source - * URLs, scheme parsers, Howler handles, request headers, cookies, or - * any payload outside this shape — the gate is the structural defense, - * and the workbench gesture surface only needs bounded identifiers and - * a callback into the audio boundary. - */ -export interface AudioUnlockContext { - /** Composition id (from the resolved navigation target). */ - readonly compositionId: string; - /** - * Scene ids in the resolved composition slice, in playback order. - * Lets the workbench prompt copy (when it lands) reflect what is - * about to play without exposing scene objects or URLs. - */ - readonly sceneIds: readonly string[]; - /** - * Navigation `AbortSignal`. Adapters that show a gesture surface - * MUST listen for abort and reject (or resolve cleanly without - * starting playback) so a superseded navigation does not start the - * old composition after the user finally clicks. - */ - readonly signal: AbortSignal; - /** - * Audio-boundary unlock callback bound to the runtime audio engine. - * The adapter calls this AFTER collecting the user gesture; the - * engine resumes its `AudioContext` so subsequent playback satisfies - * browser autoplay policy. Idempotent. - */ - readonly unlock: () => Promise; -} - -/** - * PUL-F030 / ADR-029: signature of the workbench-supplied unlock - * adapter. Resolves when the gate is satisfied (engine unlocked, the - * navigation may proceed); rejects when the user dismissed the gesture - * or another error prevents unlock. The loader awaits the returned - * promise BEFORE running asset preload / scene lifecycle for the - * present-mode composition. - */ -export type AudioUnlockAdapter = (gate: AudioUnlockContext) => Promise; +// PUL-F031 / ADR-031 chrome adapter + PUL-F030 / ADR-029 unlock-gate +// types live in `./scene-loader-guard` alongside the helpers that +// consume them; re-exported here (the imported bindings above) so the +// loader's public surface is byte-identical. +export type { AudioUnlockAdapter, AudioUnlockContext, WorkbenchChromeAdapter }; /** * Returned by {@link createSceneLoader}. Each call to `handle(target)` @@ -586,148 +529,11 @@ function isPureAbort(err: unknown, signal: AbortSignal): boolean { ); } -/** - * The audio source URLs the resolved (possibly head-truncated) slice - * declared as audio — every scene's static {@link import('./scene').SceneModule.audio} - * list in the slice (just the head scene's for a bare `kind: 'scene'` - * target, the full composition slice's for composition targets). The - * per-navigation audio service uses this as the SCENE-FACING source - * allowlist so `ctx.audio.load()` can only register URLs the scene - * EXPLICITLY declared as audio — not any URL that happens to be in - * `scene.assets`. This makes the PUL-F030 / ADR-029 unlock-gate - * predicate ({@link import('./scene').sceneDeclaresAudio}) AND the - * audio-service allowlist consistent: a scene that registers audio - * MUST declare it in `scene.audio`, so the present-mode unlock gate - * cannot be bypassed by a scene that hides its audio in `assets` - * (codex review, cycle 1 — class finding "audio gate can be bypassed - * by undeclared ctx.audio loads"). `scene.audio` is validated at the - * schema boundary to be a subset of `scene.assets`, so the preloader - * still warms every declared audio URL. - * - * The PUL-F014 composition audio bed is deliberately NOT added here. - * The bed is composition-owned runtime infrastructure, not a - * `scene.audio` entry, and exposing its source through the scene - * allowlist would let a scene `ctx.audio.load()` the bed URL as its - * own sound — playing the bed even under `mode=standalone` where it - * is suppressed. The audio service gates the bed against the bed's - * OWN declared `src` inside `startBed`, so the scene allowlist stays - * limited to `scene.audio` (codex review, cycle 1 — "composition bed - * source leaks into scene audio allowlist"). - * - * Pure function (no closure captures), hoisted to module scope so the - * loader factory does not recreate it per instance. - */ -function collectAudioSources(target: SceneNavigationTarget): readonly string[] { - return target.composition === undefined - ? target.scene.audio - : target.composition.sceneSlice.flatMap((scene) => scene.audio); -} - -/** - * Count, per scene id, how many times it occurs in the resolved - * navigation slice (issue #99). The loader's occurrence-safe audio - * teardown consults this so the scene-scoped audio group - * (`group: `, shared by every occurrence of that scene because - * `ctx.audio` is one slice-scoped service) is stopped exactly once — - * when the LAST occurrence of that id has been cleaned up — rather than - * once per occurrence. Stopping it on the first occurrence's cleanup - * would tear down a still-active sibling occurrence's audio. For a - * single-occurrence scene the count is `1`, so teardown fires on its - * only cleanup, unchanged. Pure function (no closure captures), hoisted - * to module scope so the loader factory does not recreate it per - * instance. - */ -function countSceneOccurrences(target: SceneNavigationTarget): Map { - const scenes = target.composition === undefined ? [target.scene] : target.composition.sceneSlice; - const counts = new Map(); - for (const scene of scenes) { - counts.set(scene.id, (counts.get(scene.id) ?? 0) + 1); - } - return counts; -} - -/** - * Map the effective workbench mode to the per-navigation - * {@link AudioOutputPolicy} (PUL-F024 / PUL-F026 / ADR-004): - * - * - `'rehearsal'` → `'log-cues'` - * - `'screenshot'` / `'paused'` → `'silent'` - * - every other lifecycle-running mode → `'audible'` - * - * Pure function (no closure captures), hoisted to module scope so the - * loader's `buildLoad` stays within Sonar's cognitive-complexity - * budget and the mode→policy table lives in one place. - */ -function audioOutputPolicyFor(mode: NavigationMode): AudioOutputPolicy { - return profileFor(mode).audioPolicy; -} - -/** - * Serialize a {@link NavigationLocator} into a stable, collision-free - * string. Every locator kind contributes its discriminant plus its - * identifier fields, so two distinct addressed targets never fold to - * the same string. Pure helper for {@link deriveNavigationSeed}. - */ -function serializeLocator(locator: NavigationLocator): string { - switch (locator.kind) { - case 'none': - return 'none'; - case 'scene': - return `scene:${locator.scene}`; - case 'composition': - return `composition:${locator.composition}`; - case 'composition-scene': - return `composition:${locator.composition}/scene:${locator.scene}`; - case 'composition-index': - return `composition:${locator.composition}/index:${locator.index}`; - } -} - -/** - * PUL-F018 / ADR-021: derive the per-navigation deterministic seed - * string for the scene-context RNG ({@link WorkbenchSceneCtx.rng}). - * - * The seed is built only from bounded, deterministic inputs the - * workbench URL already carries: the normalized navigation locator - * (the addressed scene / composition / index), the addressed beat, - * and `PULSAR_RUNTIME_VERSION` — ADR-021's "bundle/runtime revision - * literal" so a code revision changes the seed. It deliberately does - * NOT read the wall clock, `localStorage`, `sessionStorage`, cookies, - * `history.state`, `process.env`, `process.argv`, or the workbench - * `mode` (the addressed frame is mode-independent — `?scene=x&beat=y` - * names the same frame whether captured or played). - * - * Under `mode=screenshot` this makes the captured frame reproducible: - * the same workbench URL derives the same seed and so replays the same - * random sequence across reloads. A future explicit `seed=` URL - * parameter would populate this same derivation through the canonical - * `parseNavigationSearch` grammar — the seam ADR-021 reserves. - * - * Pure function (no closure captures), hoisted to module scope and - * exported so the per-navigation seam is unit-testable in isolation. - */ -export function deriveNavigationSeed(target: NavigationTarget): string { - return [ - 'pulsar-rng', - serializeLocator(target.locator), - `beat:${target.beat ?? ''}`, - `v:${PULSAR_RUNTIME_VERSION}`, - ].join('|'); -} - -/** - * PUL-F018 / ADR-021: combine the per-navigation seed from - * {@link deriveNavigationSeed} with one scene occurrence's identity - * ({@link SceneActivation}) so every occurrence gets its OWN seeded - * generator. A composition slice that repeats a scene id (issue #99) - * therefore hands each occurrence an independent random stream — one - * occurrence's draws cannot perturb a sibling's draw order, the - * RNG-scoping the ADR-021 guardrail requires. Pure function, hoisted - * to module scope. - */ -function deriveActivationSeed(navigationSeed: string, activation: SceneActivation): string { - return `${navigationSeed}|occ:${activation.sceneId}#${activation.entryIndex}.${activation.occurrence}`; -} +// PUL-F018 / ADR-021: the per-navigation deterministic RNG seed seam +// lives in `./scene-loader-ctx` alongside the audio/ctx construction it +// feeds; re-exported here so the unit-test seam stays addressable on +// the loader's public surface. +export { deriveNavigationSeed }; /** * One in-flight load: the abort signal that cancels it, the promise @@ -777,21 +583,6 @@ type NavigationEvent = | { readonly kind: 'target'; readonly target: NavigationTarget } | { readonly kind: 'error'; readonly err: unknown }; -/** - * PUL-F030 / ADR-029: the internal gate-prelude callback `buildLoad` - * invokes BEFORE the resolver lifecycle. Distinct from the public - * {@link AudioUnlockAdapter}: the public adapter receives a semantic - * composition context (composition id, scene ids, signal, unlock); - * this internal helper is what `buildLoad` actually awaits, with the - * composition context already partially-applied by `buildUnlockGate`. - * The adapter is workbench-supplied; the helper is the loader's - * internal closure over it. - */ -type UnlockGate = (env: { - readonly signal: AbortSignal; - readonly unlock: () => Promise; -}) => Promise; - export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { const { stage } = options; const onError = options.onError ?? ((err) => console.error(err)); @@ -951,57 +742,11 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { setStageAttr(ATTR_ERROR, rendered); }; - /** - * Defense-in-depth for ADR-013's `beat` grammar rules. - * `parseNavigationSearch` enforces both on URL input, but - * `NavigationTarget` is an exported type that non-parser callers - * (event-detail unmarshaling, future test harnesses, programmatic - * navigation) can construct directly. Re-checking at the loader - * boundary stops a hand-built target with an invalid `beat` - * (wrong shape, or paired with a non-scene-like locator) from - * reaching the runner with a value the parser would have - * rejected. Returns an `Error` with the parser's exact grammar - * message when the target is invalid, or `null` when no further - * check is needed. - */ - const validateBeatGrammar = (target: NavigationTarget): Error | null => { - if (target.beat === undefined) return null; - if (!isKebabIdentifier(target.beat)) { - return new Error( - `navigation grammar is invalid: "beat" must be a non-empty lowercase kebab-case string (${KEBAB_IDENTIFIER_FORM})`, - ); - } - const k = target.locator.kind; - if (k !== 'scene' && k !== 'composition-scene' && k !== 'composition-index') { - return new Error( - 'navigation grammar is invalid: "beat" requires a scene-like target ("scene", "composition" + "scene", or "composition" + "index")', - ); - } - return null; - }; - - /** - * Defense-in-depth for ADR-007's mode allowlist (PUL-F012). - * `parseNavigationSearch` validates `mode` against - * `NAVIGATION_MODES` on URL input, but `NavigationTarget` is an - * exported type that non-parser callers (event-detail unmarshaling, - * future test harnesses, programmatic navigation) can construct - * directly. Re-checking at the loader boundary stops a hand-built - * target with a mode the parser would have rejected from reaching - * `effectiveMode` and `buildCtx`, where it would propagate to - * scenes as `ctx.mode`. Returns an `Error` with the parser's exact - * grammar message when the target is invalid, or `null` when no - * further check is needed. - */ - const validateModeGrammar = (target: NavigationTarget): Error | null => { - if (target.mode === undefined) return null; - if (!(NAVIGATION_MODES as readonly string[]).includes(target.mode)) { - return new Error( - `navigation grammar is invalid: "mode" unknown mode "${target.mode}" — allowed: ${NAVIGATION_MODES.join(', ')}`, - ); - } - return null; - }; + // ADR-013 `beat` + ADR-007 `mode` defense-in-depth re-checks now live + // in `./navigation` (`validateBeatGrammar` / `validateModeGrammar`), + // consuming the shared `NAVIGATION_GRAMMAR` rule source so the loader's + // trust-seam diagnostics stay byte-identical to the parser's without + // re-declaring the rules here. /** * PUL-F011 / ADR-015: build the non-fatal callback the loader @@ -1067,427 +812,189 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { }; }; - /** - * Construct the per-navigation presenter pipeline: - * - `presenterAbort` — a separate `AbortController` whose signal - * drives the `PresenterController`'s teardown. Distinct from - * the navigation `controller` so the navigation signal can - * stay un-aborted across a successful completion (PUL-F013 - * boundary). Wired to fire on navigation abort AND aborted by - * `runTarget`'s `finally` on normal completion. - * - `presenter` — the `PresenterController` itself (PUL-F020 / - * ADR-023), with the PUL-F025 audio handler attached. - * - * Both are `null` / `undefined` when the loader does NOT build a - * controller for this navigation: non-present mode OR the workbench - * did not supply a `presenterCommands` source. Hoisted out of - * `buildLoad` to keep that function within Sonar's - * cognitive-complexity budget. - * - * The PUL-F025 audio handler (presenter master mute / ADR-004) - * lives here — the only seam where both the controller and the - * per-navigation `AudioService` are in scope. On - * `'toggle-master-mute'` it reads the engine's current master - * mute and flips it via the audio boundary; `audio.mute()` - * validates the boolean (PUL-F024) and is inert post-dispose. - * Master mute is engine-level runtime state so the flip survives - * scene cleanup and is observable by sibling services backed by - * the same engine (pinned in `audio.test.ts`). The handler is - * additive — the runner still receives every kind on its own - * `input.presenter.subscribe(...)`. The controller's per-handler - * `try/catch` already routes any throw through the same `onError`. - */ - const buildPresenterPipe = ( - mode: NavigationMode, - controller: AbortController, - audio: AudioService, - ): { - readonly presenter: ReturnType | undefined; - readonly presenterAbort: AbortController | null; - } => { - if (mode !== 'present' || options.presenterCommands === undefined) { - return { presenter: undefined, presenterAbort: null }; - } - const presenterAbort = new AbortController(); - // Propagate navigation abort → presenter abort. Without this - // wiring, supersession-time `controller.abort()` would not tear - // down the presenter controller (bound to `presenterAbort.signal`, - // not `controller.signal`). - if (controller.signal.aborted) { - presenterAbort.abort(); - } else { - controller.signal.addEventListener('abort', () => presenterAbort.abort(), { once: true }); - } - const presenter = createPresenterController( - options.presenterCommands, - presenterAbort.signal, - onError, - ); - // PUL-F025 / ADR-004 audio handler — see method header. - presenter.subscribe((cmd) => { - if (cmd.kind !== 'toggle-master-mute') return; - audio.mute(!audio.isMuted()); - }); - return { presenter, presenterAbort }; + // The per-navigation audio service + presenter pipe + ctx factory live + // in `./scene-loader-ctx` (`buildNavigationServices`); the present-mode + // unlock-gate predicate (`resolveUnlockGate`) and the composition chrome + // dispatch policy (`applyChromeForTarget`) live in `./scene-loader-guard`. + // The loader binds its instance deps once and calls them. + const navigationServicesDeps = { + audioEngine, + ...(options.assetPolicy === undefined ? {} : { assetPolicy: options.assetPolicy }), + ...(options.onAudioCue === undefined ? {} : { onAudioCue: options.onAudioCue }), + ...(options.presenterCommands === undefined + ? {} + : { presenterCommands: options.presenterCommands }), + onError, + buildCtx: options.buildCtx, }; /** - * Build the in-flight load record: an `AbortController`, the - * preloader factory's per-load preloader (a synchronous failure - * is rolled back through `resetStageAttrs()` + `surfaceError`), - * and the bridge call that drives the lifecycle. Returns the - * record, or `null` when the preloader factory threw — in which - * case the caller has already been told via `surfaceError` and - * should bail. Hoisted out of `runTarget` so the latter stays - * within Sonar's cognitive-complexity budget. - */ - /** - * PUL-F030 / ADR-029: build the per-navigation closure that calls - * the workbench-supplied unlock adapter with the composition context - * captured here and the navigation-bound signal + engine-bound - * unlock callback supplied at gate-invocation time by `buildLoad`. - * Partial application keeps the composition context out of the - * `buildLoad` body so the latter stays within Sonar's cognitive- - * complexity budget. - * - * Pure factory — captures only `adapter` and `composition`. The - * adapter receives bounded semantic context: composition id, scene - * ids in playback order, the navigation `AbortSignal`, and the - * engine-bound `unlock()` callback. It does NOT receive scene - * objects, source URLs, asset payloads, or Howler handles (per - * ADR-029's "workbench gesture surface" guardrail). + * Composition-level chrome policy (PUL-F031 / ADR-031), bound to this + * loader's chrome adapter + registries. No-op when no chrome adapter + * was supplied. See `applyChromeForTarget` in `./scene-loader-guard`. */ - const buildUnlockGate = ( - adapter: AudioUnlockAdapter, - composition: SceneNavigationTarget['composition'] & object, - ): UnlockGate => { - const compositionId = composition.id; - const sceneIds = Object.freeze(composition.sceneSlice.map((scene) => scene.id)); - return ({ signal, unlock }) => - adapter({ - compositionId, - sceneIds, - signal, - unlock, - }); + const dispatchChromeForTarget = (target: NavigationTarget): void => { + const chrome = options.chrome; + if (chrome === undefined) return; + applyChromeForTarget( + chrome, + { scenes: options.scenes, compositions: options.compositions }, + target, + ); }; /** - * PUL-F030 / ADR-029: decide whether the present-mode audio unlock - * gate applies to this navigation, and if so build the gate. Returns - * one of: - * - * - `null` — the gate does not apply (mode is not present, no - * composition slice, or no scene in the slice declares audio). - * The lifecycle runs without a prelude. - * - `'fail-loud'` — the gate applies but no `audioUnlockAdapter` - * is supplied. The caller surfaces a navigation-level error and - * skips the lifecycle. - * - `UnlockGate` — a closure that invokes the workbench adapter - * with the composition context. The lifecycle awaits it before - * preload / `create(ctx)` / `timeline(ctx)` / master playback. - * - * Hoisted out of `runTarget` so the latter stays within Sonar's - * cognitive-complexity budget (S3776). + * Build the resolver run-input for one navigation. Threads the + * per-occurrence ctx factory, preloader, beat + missing-beat callback, + * the mode-profile runner hints (repeat / hold / cueGate / screenshot), + * the shared scrub cue gate, the presenter controller + its error + * sink, the occurrence-safe per-scene audio teardown, and the + * scene-level failure isolation (PUL-F029) — see the individual + * option docs on {@link loadSceneNavigationTarget}. */ - const resolveUnlockGate = ( - target: NavigationTarget, + const buildRunInput = ( resolved: SceneNavigationTarget, - ): UnlockGate | 'fail-loud' | null => { - if (effectiveMode(target) !== 'present') return null; - const composition = resolved.composition; - if (composition === undefined) return null; - if (!composition.sceneSlice.some(sceneDeclaresAudio)) return null; - if (options.audioUnlockAdapter === undefined) return 'fail-loud'; - return buildUnlockGate(options.audioUnlockAdapter, composition); - }; - - const chromeBehaviorFromResolved = (resolved: SceneNavigationTarget | null): unknown => { - const head = resolved?.composition?.manifestSlice[0]; - if (head === null || typeof head !== 'object') return undefined; - return (head as { readonly behavior?: { readonly chrome?: unknown } }).behavior?.chrome; - }; - - const chromeBehaviorForTarget = (target: NavigationTarget): unknown => { - try { - return chromeBehaviorFromResolved( - resolveSceneNavigation(target, { - scenes: options.scenes, - compositions: options.compositions, - }), - ); - } catch { - return undefined; - } - }; - - const applyChromeDispatchPolicy = ( - chrome: WorkbenchChromeAdapter, + services: ReturnType, + preloadAssets: AssetPreloader, + signal: AbortSignal, mode: NavigationMode, - chromeBehavior: unknown, - ): void => { - const forcedVisibility = chromeBehavior === 'hidden' ? 'hidden' : null; - const atmosphere = - typeof chromeBehavior === 'object' && - chromeBehavior !== null && - (chromeBehavior as { readonly atmosphere?: unknown }).atmosphere === 'cinematic' - ? 'cinematic' - : null; - chrome.setForcedVisibility?.(forcedVisibility); - chrome.setAtmosphere?.(atmosphere); - chrome.applyMode(mode); - }; - - /** - * Composition-level chrome policy: the head manifest entry's - * `behavior.chrome` can hide chrome or opt into cinematic atmosphere. - * The default is explicit reset (`forcedVisibility=null`, - * `atmosphere=null`) so a navigation away from an atmospheric deck - * clears that state immediately, before serialized scene cleanup. - */ - const applyChromeForTarget = (target: NavigationTarget): void => { - const chrome = options.chrome; - if (chrome === undefined) return; - applyChromeDispatchPolicy(chrome, effectiveMode(target), chromeBehaviorForTarget(target)); + beat: string | undefined, + audioCueGate: CueGateControl | undefined, + ): Parameters[1] => { + const onBeatMissing = buildOnBeatMissing(beat, resolved.scene.id, signal); + const onSceneFailed = buildOnSceneFailed( + signal, + mode, + resolved.composition === undefined + ? undefined + : { id: resolved.composition.id, startIndex: resolved.composition.startIndex }, + ); + // issue #99: occurrence-safe per-scene audio teardown — the + // scene-scoped group is stopped only when the LAST occurrence of + // that id has been cleaned up. + const remainingOccurrences = countSceneOccurrences(resolved); + const onSceneCleaned = (activation: SceneActivation): void => { + const left = (remainingOccurrences.get(activation.sceneId) ?? 1) - 1; + remainingOccurrences.set(activation.sceneId, left); + if (left <= 0) services.audio.stopGroup(activation.sceneId); + }; + return { + ctx: services.buildSceneCtx, + preloadAssets, + timeline: options.timeline, + signal, + ...(beat === undefined ? {} : { beat }), + ...(onBeatMissing === undefined ? {} : { onBeatMissing }), + // Head-scene runner hints from the mode profile; empty for modes + // that set none. + ...profileFor(mode).runnerHints, + ...(audioCueGate === undefined ? {} : { audioCueGate }), + ...(services.presenter === undefined + ? {} + : { presenter: services.presenter, onPresenterError: onError }), + onSceneCleaned, + onSceneFailed, + }; }; const buildLoad = ( resolved: SceneNavigationTarget, target: NavigationTarget, unlockGate: UnlockGate | null, - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: existing pre-rule offender (cognitive complexity 18). buildLoad is the navigation-mode dispatch seam — derives effective mode, builds audio + presenter pipes, and threads abort signals; refactor tracked in docs/design/complexity-backlog.md. ): InFlightLoad | null => { const controller = new AbortController(); // PUL-F012 / ADR-007: mode dispatch lives at the runtime-core seam. - // The loader derives the effective mode from the parsed target via - // `effectiveMode` (URL-only — no localStorage, sessionStorage, - // cookies, history.state, or cached state); every navigation - // re-derives from its own target, so a previous non-`present` mode - // cannot leak into a subsequent `mode`-less URL. The mode also - // selects whether the audio service is `silent` (screenshot / - // paused suppress audible playback — ADR-019 / ADR-021). + // `effectiveMode` re-derives mode from the parsed target (URL-only — + // no storage / cookies / history.state / cached state) on every + // navigation, so a previous non-`present` mode cannot leak. const mode = effectiveMode(target); - // Mode is data: the profile decides audio policy, slice truncation, - // bed suppression, the scrub cue gate, and the head-scene runner - // hints. See ./mode-profile.ts. const profile = profileFor(mode); - // PUL-F018 / ADR-021: the per-navigation deterministic RNG seed, - // derived from the addressed URL target. Computed once here and - // combined per occurrence in `buildSceneCtx` so every scene - // occurrence gets its own seeded `ctx.rng`. - const navigationSeed = deriveNavigationSeed(target); let preloadAssets: AssetPreloader; try { preloadAssets = options.createPreloader(controller.signal); } catch (err) { - // Roll back the success-state attrs we just wrote and surface - // the error so the stage doesn't lie about a half-loaded scene. - // `resetStageAttrs()` clears all three navigation attrs in - // lock-step with the success-path reset, so this rollback - // cannot drift if a fourth navigation attr is added later. + // Roll back the success-state attrs and surface the error so the + // stage doesn't lie about a half-loaded scene. resetStageAttrs(); surfaceError(err); return null; } - // PUL-F024 / PUL-F026 / ADR-004: the per-navigation audio service. - // Built over `audioEngine`, scoped to `controller.signal` (abort ⇒ - // every sound stopped + unloaded), restricted to the URLs the slice - // declared in `scene.audio` (PUL-F030 / ADR-029 — every audio entry - // must also be in `scene.assets` so the preloader warmed it), and - // with a per-mode `AudioOutputPolicy`: - // - `'silent'` under `mode=screenshot` / `mode=paused` (audible - // playback suppressed — ADR-019 / ADR-021). - // - `'log-cues'` under `mode=rehearsal` (audible playback - // suppressed AND every accepted audio operation - // is emitted to the workbench's optional - // `onAudioCue` sink — PUL-F026 / ADR-004). - // - `'audible'` otherwise (`present` / `standalone` / `loop` / - // `scrub`). - // - // The rehearsal policy lives entirely on this audio-service seam - // — there is no head-only runner-input hint, no slice truncation, - // no resolver semantics — so "without altering timeline state" - // (PUL-F026) is the structural invariant. The optional - // `onAudioCue` sink is threaded through unconditionally; the - // policy decides whether cues are emitted, so a non-rehearsal - // navigation pays no per-op cost even when the workbench wired a - // cue surface. - // - // Threaded into `ctx.audio` via `buildCtx`, alongside `mode` - // (PUL-F012). Built AFTER the preloader so a preloader-factory - // failure does not waste allocations, and wrapped in the same - // rollback-then-surfaceError pattern so a throwing builder does - // not leave stale stage attrs or skip the queue's error sink. - const outputPolicy = audioOutputPolicyFor(mode); - // PUL-F017 / ADR-020: under `mode=scrub` build the dynamic audio - // cue gate ONCE and share the single instance between the audio - // service (which consults `isEligible()` to suppress a cue's - // output) and the timeline adapter (which toggles it by playhead - // direction). It starts closed — a scrub master is held at its - // cursor, not playing monotonically forward — and the GSAP adapter - // opens it on `play()`. Absent for every other mode, so non-scrub - // navigations build an ungated audio service exactly as before. + // PUL-F017 / ADR-020: under `mode=scrub` build the dynamic audio cue + // gate ONCE and share it between the audio service (consults + // `isEligible()`) and the timeline adapter (toggles by playhead + // direction). Starts closed; the GSAP adapter opens it on `play()`. const audioCueGate: CueGateControl | undefined = profile.buildScrubCueGate ? createCueGate(false) : undefined; - // issue #99: the loader threads a per-occurrence ctx factory rather - // than a single ctx. `buildCtx` builds the navigation-scoped base - // (stage / gsap / audio / mode / presenter / chrome) once; the - // factory adds each occurrence's `SceneActivation` so a slice that - // repeats a scene id gives every occurrence a distinct `ctx`. - let buildSceneCtx: (activation: SceneActivation) => unknown; - let audio: AudioService; - // Per-navigation presenter pipe is built BEFORE buildCtx so the - // controller can be threaded onto `ctx.presenter` for scenes that - // subscribe to presenter `advance` commands directly (calgary-style - // `while (!state.advanceSignal)` loop scenes). Declared here so the - // try/catch can construct audio first (the pipe's mute handler - // closes over audio), then ctx with both refs. - let presenter: ReturnType | undefined; - let presenterAbort: AbortController | null = null; + // PUL-F024 / PUL-F025 / PUL-F018: the per-navigation audio service, + // presenter pipe, and per-occurrence ctx factory. Built AFTER the + // preloader so a preloader-factory failure wastes no allocations, + // and wrapped in the same rollback-then-surfaceError pattern so a + // throwing builder leaves no stale stage attrs and skips no error + // sink. On throw the controller is aborted first so any signal-tied + // resource (preloader fetch listener, audio stop-on-abort hook) + // observes cancellation rather than being GC'd un-aborted. + let services: ReturnType; try { - audio = createAudioService(audioEngine, { - signal: controller.signal, - outputPolicy, - allowedSources: collectAudioSources(resolved), - ...(options.assetPolicy === undefined ? {} : { assetPolicy: options.assetPolicy }), - onError, - ...(options.onAudioCue === undefined ? {} : { onCue: options.onAudioCue }), - // PUL-F014 / ADR-004: the composition audio bed, played - // underneath the slice — suppressed under `mode=standalone`, - // where the scene runs as if no surrounding composition - // existed. The resolver snapshots the declaration onto the - // composition context; the loader is the mode-dispatch point - // that decides bed vs no-bed, keeping the resolver mode-opaque. - ...(resolved.composition?.audioBed === undefined - ? {} - : { bed: resolved.composition.audioBed }), - bedSuppressed: profile.suppressBed, - // PUL-F017 / ADR-020: the shared cue gate (scrub only). The - // audio service consults it; the timeline adapter toggles it. - ...(audioCueGate === undefined ? {} : { cueGate: audioCueGate }), - }); - const pipe = buildPresenterPipe(mode, controller, audio); - presenter = pipe.presenter; - presenterAbort = pipe.presenterAbort; - const baseCtx = options.buildCtx(mode, audio, presenter); - // PUL-F018 / ADR-021: the loader adds each occurrence's - // `activation` AND a deterministic `rng` seeded from the - // navigation seed + that occurrence's identity, so a slice that - // repeats a scene id gives every occurrence an independent - // random stream. - buildSceneCtx = (activation) => ({ - ...baseCtx, - activation, - rng: createSeededRng(deriveActivationSeed(navigationSeed, activation)), - }); + services = buildNavigationServices( + navigationServicesDeps, + resolved, + target, + mode, + controller, + audioCueGate, + ); } catch (err) { - // Abort the freshly-created controller before bailing so any - // signal-tied resource (the preloader factory's fetch listener, - // the audio service's stop-on-abort hook) observes cancellation - // and releases. Without this, the signal is GC'd in the - // never-aborted state and any abort-keyed listener runs at GC - // time (or never). controller.abort(); resetStageAttrs(); surfaceError(err); return null; } - const beat = target.beat; - const onBeatMissing = buildOnBeatMissing(beat, resolved.scene.id, controller.signal); - // Head-scene runner-input hints (repeat / hold / cueGate / - // screenshot) per the mode profile. The resolver scopes each hint - // to the addressed head scene only; following composition entries - // never see them. The modes that set them are mutually exclusive - // at the URL boundary, so at most one field is present. See - // ./mode-profile.ts for the per-mode table and PUL-F015..F018 / - // ADR-018..021 for the semantics. - const runnerHints = profile.runnerHints; - // `presenter` / `presenterAbort` already built above (before - // buildCtx) so the controller could be threaded onto `ctx.presenter`. - const onSceneFailed = buildOnSceneFailed( + const runInput = buildRunInput( + resolved, + services, + preloadAssets, controller.signal, mode, - resolved.composition === undefined - ? undefined - : { id: resolved.composition.id, startIndex: resolved.composition.startIndex }, + target.beat, + audioCueGate, ); - // PUL-F024 / ADR-004 + issue #99: occurrence-safe per-scene audio - // teardown. A scene scopes a sound to itself with - // `play(id, { group: })`; that group is shared by - // every occurrence of the scene in the slice (`ctx.audio` is one - // slice-scoped service). The runtime stops the group only when the - // LAST occurrence of that id has been cleaned up — counting down - // per scene id — so an earlier occurrence's cleanup never stops a - // still-active sibling occurrence's audio. A single-occurrence - // scene counts `1` and tears down on its only cleanup, unchanged. - const remainingOccurrences = countSceneOccurrences(resolved); - const onSceneCleaned = (activation: SceneActivation): void => { - const left = (remainingOccurrences.get(activation.sceneId) ?? 1) - 1; - remainingOccurrences.set(activation.sceneId, left); - if (left <= 0) audio.stopGroup(activation.sceneId); - }; - const runLifecycle = (): Promise => - loadSceneNavigationTarget(resolved, { - ctx: buildSceneCtx, - preloadAssets, - timeline: options.timeline, - signal: controller.signal, - ...(beat === undefined ? {} : { beat }), - ...(onBeatMissing === undefined ? {} : { onBeatMissing }), - // Head-scene runner hints (repeat / hold / cueGate / screenshot) - // from the mode profile; an empty object for modes that set none. - ...runnerHints, - // PUL-F017 / ADR-020: the same gate handed to the audio service - // above — the timeline adapter toggles it by playhead direction. - ...(audioCueGate === undefined ? {} : { audioCueGate }), - // Forward `presenter` AND the `onError` sink so the - // resolver's per-scene wrapper around `presenter` can - // route runner-handler exceptions / unknown-kind drops - // through the same diagnostic channel as every other - // navigation-level error (codex review, cycle 2). - ...(presenter === undefined ? {} : { presenter, onPresenterError: onError }), - // PUL-F024 / ADR-004 + issue #99: runtime-guaranteed per-scene - // audio teardown — see `onSceneCleaned` above. (`stopGroup` is - // a no-op when the group is empty or the service is disposed.) - onSceneCleaned, - // PUL-F029 / ADR-028: scene-level error isolation. Each - // isolated `create` / `timeline` / `cleanup` failure becomes - // a stage attribute entry + `onError` call without halting - // the active composition. - onSceneFailed, - }); + const runLifecycle = (): Promise => loadSceneNavigationTarget(resolved, runInput); // PUL-F030 / ADR-029: when the gate applies, the settled promise // begins with the adapter await — the lifecycle runs only after - // unlock succeeds AND the navigation has not been aborted. The - // gate's signal IS the navigation signal, so supersession / - // dispose / popstate aborts the gate; adapters that respect the - // signal can reject deterministically. The audio service / ctx / - // preloader are constructed BEFORE the gate (above), but none of - // them touches the network or DOM until `loadSceneNavigationTarget` - // calls `preloadAssets(scene)` and `create(ctx)` — so the gate - // running first preserves the structural invariant "no lifecycle - // work before unlock." + // unlock succeeds AND the navigation has not been aborted. The gate's + // signal IS the navigation signal. None of the services constructed + // above touch the network or DOM until `loadSceneNavigationTarget` + // calls `preloadAssets(scene)` / `create(ctx)`, so running the gate + // first preserves the "no lifecycle work before unlock" invariant. const settled = unlockGate === null ? runLifecycle() - : (async (): Promise => { - await unlockGate({ - signal: controller.signal, - unlock: () => audioEngine.unlock(), - }); - if (controller.signal.aborted) return; - return runLifecycle(); - })(); + : runGatedLifecycle(unlockGate, controller, runLifecycle); return { controller, settled, silent: false, - audio, - presenterAbort, + audio: services.audio, + presenterAbort: services.presenterAbort, }; }; + /** + * PUL-F030 / ADR-029: await the unlock gate, then run the lifecycle + * only if the navigation has not been superseded. Extracted so + * `buildLoad` stays within the cognitive-complexity budget. + */ + const runGatedLifecycle = async ( + unlockGate: UnlockGate, + controller: AbortController, + runLifecycle: () => Promise, + ): Promise => { + await unlockGate({ signal: controller.signal, unlock: () => audioEngine.unlock() }); + if (controller.signal.aborted) return; + return runLifecycle(); + }; + /** * Truncate the validated composition slice to its addressed head * entry under modes that mean "run only the addressed scene." @@ -1664,16 +1171,70 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { }; }; - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: existing pre-rule offender (cognitive complexity 23). runTarget is the per-navigation orchestrator — beat/mode grammar validation, dispatch routing across prompter / screenshot / present, and abort handling; refactor tracked in docs/design/complexity-backlog.md. - const runTarget = async (target: NavigationTarget): Promise => { - const beatErr = validateBeatGrammar(target); - if (beatErr !== null) { - surfaceError(beatErr); - return; + /** + * Dispatch the resolved target to a non-prompter load: truncate the + * slice for single-scene modes (PUL-F014..F018), resolve the + * present-mode audio unlock gate (PUL-F030), and build the load. + * Returns `null` when the gate fails loud (no adapter supplied — a + * workbench-bootstrap defect, surfaced after stage-attr reset) or the + * load builder bailed. `mode=prompter` is dispatched by the caller + * (it bypasses the resolver lifecycle structurally). + */ + const dispatchLifecycleLoad = ( + resolved: SceneNavigationTarget, + target: NavigationTarget, + ): InFlightLoad | null => { + const runnable = applySingleSceneSlice(resolved, target); + const gateOrFailure = resolveUnlockGate(options.audioUnlockAdapter, target, resolved); + if (gateOrFailure === 'fail-loud') { + resetStageAttrs(); + surfaceError( + new Error( + 'audio unlock gate: present-mode composition declares audio but no audioUnlockAdapter was supplied — workbench bootstrap must wire one to satisfy PUL-F030 / ADR-029', + ), + ); + return null; + } + return buildLoad(runnable, target, gateOrFailure); + }; + + /** + * Await the in-flight load to settle, then run the per-navigation + * teardown exactly once. Suppresses only the resolver's own "aborted" + * wrapper (the superseding event owns the visible state); every other + * error — including multi-fault `AggregateError`s — still surfaces. + */ + const awaitLoad = async (load: InFlightLoad): Promise => { + try { + await load.settled; + } catch (err) { + if (!load.silent && !isPureAbort(err, load.controller.signal)) { + surfaceError(err); + } + } finally { + // PUL-F024 / ADR-004: the navigation is over — stop + unload every + // sound it created (idempotent; harmless on the abort paths where + // the signal binding already disposed the service). + load.audio?.stopAll(); + // PUL-F025 / ADR-023: tear down the presenter controller's + // subscriptions on the happy path. The controller is bound to a + // SEPARATE `presenterAbort` signal (not the navigation signal) so + // the navigation signal's "un-aborted on success" invariant + // (PUL-F013) is preserved; that separate signal still fires on + // navigation abort. Idempotent. + load.presenterAbort?.abort(); + if (inFlight === load) inFlight = null; } - const modeErr = validateModeGrammar(target); - if (modeErr !== null) { - surfaceError(modeErr); + }; + + const runTarget = async (target: NavigationTarget): Promise => { + // Defense-in-depth grammar re-check (ADR-013 beat / ADR-007 mode) + // for programmatically-built targets — the parser already enforces + // these on URL input. Both validators share the `NAVIGATION_GRAMMAR` + // rule source in `./navigation`. + const grammarErr = validateBeatGrammar(target) ?? validateModeGrammar(target); + if (grammarErr !== null) { + surfaceError(grammarErr); return; } @@ -1696,76 +1257,17 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { } // PUL-F019 / ADR-022: under `mode=prompter` bypass the resolver - // lifecycle entirely. Visual rendering is suppressed structurally - // by NOT running the path that would mount scenes. The captions - // data path takes its place. No `applySingleSceneSlice` (the - // captions view consumes the FULL slice — see ADR-022 for why - // the truncation defense from F015–F018 does not apply here), - // no `buildLoad` (no preloader, no buildCtx, no runner), just a - // captions-renderer dispatch wrapped in the same abort/queue - // pattern so cleanup-before-handoff and supersession still work. - let load: InFlightLoad | null; - if (effectiveMode(target) === 'prompter') { - load = buildPrompterLoad(resolved); - } else { - const runnable = applySingleSceneSlice(resolved, target); - // PUL-F030 / ADR-029: resolve the present-mode audio unlock - // gate up front. The helper returns `'fail-loud'` when the gate - // applies but no adapter is supplied (workbench-bootstrap - // defect — the gate IS the structural defense), `null` when - // the gate does not apply or there is no composition, and a - // built unlock gate otherwise. - const gateOrFailure = resolveUnlockGate(target, resolved); - if (gateOrFailure === 'fail-loud') { - resetStageAttrs(); - surfaceError( - new Error( - 'audio unlock gate: present-mode composition declares audio but no audioUnlockAdapter was supplied — workbench bootstrap must wire one to satisfy PUL-F030 / ADR-029', - ), - ); - return; - } - load = buildLoad(runnable, target, gateOrFailure); - } + // lifecycle entirely (no `applySingleSceneSlice`, no `buildLoad`) — + // visual rendering is suppressed structurally by NOT running the + // path that would mount scenes. The captions data path takes its + // place, wrapped in the same abort/queue pattern. + const load = + effectiveMode(target) === 'prompter' + ? buildPrompterLoad(resolved) + : dispatchLifecycleLoad(resolved, target); if (load === null) return; inFlight = load; - - try { - await load.settled; - } catch (err) { - // Suppress only the resolver's own "aborted" wrapper error — - // the new event that triggered the abort owns the visible - // state. AggregateErrors (multi-fault: phase + cleanup) and - // any non-abort errors still surface so cleanup failures - // during an aborted lifecycle are not silently dropped. - if (!load.silent && !isPureAbort(err, load.controller.signal)) { - surfaceError(err); - } - } finally { - // PUL-F024 / ADR-004: the navigation is over — stop + unload - // every sound it created. On supersession / dispose the signal - // binding already disposed the service, so this is the - // happy-path-completion path (the resolver ran every scene's - // `cleanup(ctx)`, then `load.settled` resolved); `stopAll()` is - // idempotent so the double-call on the abort paths is harmless. - load.audio?.stopAll(); - // PUL-F025 / ADR-023 (codex review, post-PUL-F025): tear down - // the presenter controller's subscriptions on the happy path - // too. The presenter controller is built against a *separate* - // `presenterAbort` signal in `buildLoad` (NOT the navigation - // signal) so the navigation signal's "un-aborted on success" - // invariant — pinned by the PUL-F013 boundary tests — is - // preserved. The separate signal still fires on navigation - // abort (wired in `buildLoad`), and we abort it here so the - // long-lived `PresenterCommandSource` does not accumulate - // stale wrappers across successful present-mode completions - // (the loader's PUL-F025 mute handler, plus any runner-owned - // subscription that forgot to unsubscribe at scene-exit). - // Idempotent: a subsequent supersession-time abort of the - // same `presenterAbort` is a no-op. - load.presenterAbort?.abort(); - if (inFlight === load) inFlight = null; - } + await awaitLoad(load); }; /** @@ -1797,7 +1299,7 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { if (options.chrome === undefined) return null; if (validateModeGrammar(target) !== null) return null; try { - applyChromeForTarget(target); + dispatchChromeForTarget(target); return null; } catch (err) { return err instanceof Error ? err : new Error(String(err)); diff --git a/tests/runtime/policy-biome-complexity-gate.test.ts b/tests/runtime/policy-biome-complexity-gate.test.ts index 9175fa5..beb8ff4 100644 --- a/tests/runtime/policy-biome-complexity-gate.test.ts +++ b/tests/runtime/policy-biome-complexity-gate.test.ts @@ -47,7 +47,6 @@ const SUPPRESSION_PREFIX = '// biome-ignore lint/complexity/noExcessiveCognitive const EXPECTED_RUNTIME_FILES_WITH_SUPPRESSIONS = [ 'src/runtime/asset-preloader.ts', 'src/runtime/audio.ts', - 'src/runtime/scene-loader.ts', ] as const; const EXPECTED_TEST_FILES_WITH_SUPPRESSIONS = [ From 9a26f110defecfc20548ebe486cc1b49b9a19a75 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 09:28:15 +0200 Subject: [PATCH 04/28] refactor(timeline): extract presenter transport into an opt-in present-mode seam Move PresenterTransportState, presenterAdvance/skip, applyPresenterCommandToMaster, and wirePresenterCommands out of the always-on timeline composition path into src/runtime/presenter-transport.ts. The transport is wired onto the master only when a navigation forwards a presenter controller (mode=present); a non-present navigation never instantiates it. Keep the GSAP composition spine in timeline.ts (composeMasterTimeline, the scene label namespace, assertSceneTimeline, MasterBeat). Factor a single validateLabelTime helper, table-drive the pause-at-beat positionMaster branches, and fold the onMaster/onSegmentChange observability into a SegmentReporter object. Public signatures, data-pulsar-* attributes, and cross-engine timing behavior unchanged. --- ...+presenter-transport-extraction.changed.md | 10 + src/runtime/presenter-transport.ts | 210 ++++++++++++ src/runtime/presenter.ts | 9 +- src/runtime/timeline.ts | 322 ++++++------------ tests/runtime/timeline.test.ts | 32 ++ 5 files changed, 352 insertions(+), 231 deletions(-) create mode 100644 changelog.d/+presenter-transport-extraction.changed.md create mode 100644 src/runtime/presenter-transport.ts 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/src/runtime/presenter-transport.ts b/src/runtime/presenter-transport.ts new file mode 100644 index 0000000..74a16d0 --- /dev/null +++ b/src/runtime/presenter-transport.ts @@ -0,0 +1,210 @@ +// Presenter transport — PUL-F020 / PUL-F021 (DRAFT) / ADR-024. +// +// The opt-in seam that translates presenter commands into master-timeline +// transport actions. It is wired ONLY on the `mode=present` composition +// path (`createGsapCompositionTimeline` calls {@link wirePresenterCommands} +// when, and only when, the run input carries a `presenter` controller). +// A non-present navigation never reaches this module, so the always-on +// composition path in `timeline.ts` carries none of this state machine. +// +// ADR-024 *Cross-command precedence* is the binding contract: an explicit +// PUL-F021 `pause` is distinct from a PUL-F020 beat `hold` and a GSAP +// `addPause` advance-gate. GSAP exposes only a single `paused()` bit, so +// the runner keeps the three apart with the private {@link PresenterTransportState}. +// +// References: +// - PUL-F020 — advance / hold / skip-forward / skip-backward under +// `mode=present`; beat progression interruptible without breaking +// timeline state. +// - PUL-F021 — pause the active timeline / resume from the same point. +// - ADR-024 — presenter pause/resume precedence: only `resume` +// unfreezes an explicit pause; a beat-pacing command never does. + +import type { CompositionTimelineRunOptions } from './composition-resolver'; +import { type MasterSegment, type MasterTimeline, segmentIndexAtTime } from './timeline'; + +/** + * Per-activation presenter transport state, held inside the + * {@link wirePresenterCommands} closure (fresh per navigation). GSAP + * exposes only a single `paused()` bit, which is not a sufficient state + * model: a PUL-F020 beat `hold`, a GSAP `addPause` advance-gate, and an + * explicit PUL-F021 `pause` all read as "paused" but compose + * differently. ADR-024 *Cross-command precedence* requires the runner to + * keep them apart; these flags are that private state, scoped to the + * present-mode transport — not URL, loader, scene, or storage state. + */ +interface PresenterTransportState { + /** A PUL-F020 beat `hold` is engaged. */ + held: boolean; + /** A PUL-F021 transport `pause` freeze is engaged. */ + explicitlyPaused: boolean; + /** Current scene segment cursor in the active composition slice. */ + activeSegmentIndex: number; +} + +/** + * Release a beat hold (or a GSAP `addPause` advance-gate), or — when + * playing — seek forward to the next authored beat label OR next segment + * boundary, whichever is closer (ADR-024). Dropped while explicitly + * paused: a beat-pacing command never unfreezes a PUL-F021 pause. + */ +function presenterAdvance( + master: MasterTimeline, + segments: readonly MasterSegment[], + state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, +): void { + // ADR-024: a beat-pacing command received while explicitly paused + // MUST NOT resume playback — drop it; the frozen playhead stays put. + if (state.explicitlyPaused) return; + state.held = false; + if (master.isPaused()) { + // Release the beat hold (or a GSAP addPause advance-gate). + master.play(); + return; + } + const now = master.time(); + const beatTimes = master.beats().map((b) => b.time); + const nextBeat = beatTimes.filter((t) => t > now + 0.05).sort((a, b) => a - b)[0]; + const nextSegment = segments.find((s) => s.time > now + 0.05); + if (nextBeat === undefined && nextSegment === undefined) return; + if (nextSegment !== undefined && (nextBeat === undefined || nextSegment.time <= nextBeat)) { + master.seek(nextSegment.time); + state.activeSegmentIndex = nextSegment.index; + onSegmentChange(nextSegment); + return; + } + if (nextBeat !== undefined) { + master.seek(nextBeat); + state.activeSegmentIndex = segmentIndexAtTime(segments, nextBeat); + } +} + +/** + * Shared skip body: seek to `target`, report the new active segment, + * then — unless an explicit PUL-F021 pause is in effect — release any + * beat hold and resume playback. ADR-024 permits skip to move the frozen + * playhead while paused (presenter scrubbing), but it must not resume. + */ +function presenterSkip( + master: MasterTimeline, + state: PresenterTransportState, + target: MasterSegment | undefined, + onSegmentChange: (segment: MasterSegment) => void, +): void { + if (target === undefined) return; + master.seek(target.time); + state.activeSegmentIndex = target.index; + onSegmentChange(target); + if (state.explicitlyPaused) return; + state.held = false; + if (master.isPaused()) master.play(); +} + +function presenterSkipForward( + master: MasterTimeline, + segments: readonly MasterSegment[], + state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, +): void { + state.activeSegmentIndex = segmentIndexAtTime(segments, master.time()); + const targetIndex = Math.min(state.activeSegmentIndex + 1, segments.length - 1); + presenterSkip(master, state, segments[targetIndex], onSegmentChange); +} + +function presenterSkipBackward( + master: MasterTimeline, + segments: readonly MasterSegment[], + state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, +): void { + state.activeSegmentIndex = segmentIndexAtTime(segments, master.time()); + const targetIndex = Math.max(state.activeSegmentIndex - 1, 0); + presenterSkip(master, state, segments[targetIndex], onSegmentChange); +} + +/** + * Translate one {@link import('./presenter').PresenterCommand} into a + * master-timeline transport action. ADR-024 *Cross-command precedence* + * is the binding contract: + * + * - `hold` — engage a PUL-F020 beat hold at the current playhead. + * Idempotent; NOT a play/pause toggle. + * - `pause` — engage the explicit PUL-F021 transport freeze. + * - `resume` — release an explicit `pause` only (a `resume` with no + * explicit pause is a no-op); a `resume` that lifts a pause engaged + * while a `hold` was active leaves the master held. + * - `advance` — release a hold / gate, or seek to the next beat / + * segment. Dropped while explicitly paused. + * - `skip-forward` / `skip-backward` — seek to the next / previous + * segment start. While paused, moves the frozen playhead without + * resuming. + * - everything else (`toggle-master-mute` / `toggle-practice`) is + * audio-owned / L2-owned and ignored here. + */ +function applyPresenterCommandToMaster( + master: MasterTimeline, + segments: readonly MasterSegment[], + state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, + cmd: { readonly kind: string }, +): void { + switch (cmd.kind) { + case 'hold': + // Engage a PUL-F020 beat hold. Idempotent — NOT a toggle. + state.held = true; + master.pause(); + return; + case 'pause': + // Engage the explicit PUL-F021 transport freeze. + state.explicitlyPaused = true; + master.pause(); + return; + case 'resume': + // ADR-024: only `resume` unfreezes an explicit pause; a `resume` + // with no explicit pause in effect is a no-op. + if (!state.explicitlyPaused) return; + state.explicitlyPaused = false; + // Restore the snapshotted beat-pacing state: a `hold` engaged + // before the pause survives the resume — the master stays held. + if (state.held) return; + master.play(); + return; + case 'advance': + presenterAdvance(master, segments, state, onSegmentChange); + return; + case 'skip-forward': + presenterSkipForward(master, segments, state, onSegmentChange); + return; + case 'skip-backward': + presenterSkipBackward(master, segments, state, onSegmentChange); + return; + default: + return; // toggle-master-mute / toggle-practice / unknown — not master's concern + } +} + +/** + * Wire the per-navigation presenter controller to master-timeline + * transport. No-op when the run input carries no presenter controller — + * so a non-present navigation never instantiates {@link PresenterTransportState}. + * + * The controller is signal-bound (per-handler auto-detach on navigation + * abort), so subscriptions never accumulate across activations. + */ +export function wirePresenterCommands( + master: MasterTimeline, + segmentAnchors: readonly MasterSegment[], + opts: CompositionTimelineRunOptions, + reportSegmentChange: (segment: MasterSegment) => void, +): void { + if (opts.presenter === undefined) return; + const transport: PresenterTransportState = { + held: false, + explicitlyPaused: false, + activeSegmentIndex: segmentIndexAtTime(segmentAnchors, master.time()), + }; + opts.presenter.subscribe((cmd) => { + applyPresenterCommandToMaster(master, segmentAnchors, transport, reportSegmentChange, cmd); + }); +} diff --git a/src/runtime/presenter.ts b/src/runtime/presenter.ts index 028bc9b..dacb29b 100644 --- a/src/runtime/presenter.ts +++ b/src/runtime/presenter.ts @@ -27,10 +27,11 @@ // scene, rewrite URL/history, or persist the playhead; they are // runner-owned transport state on the same command seam, not a new // mode or lifecycle path (ADR-024). The keyboard presenter source -// (`src/system/presenter/keyboard-source.ts`) and the GSAP runner's -// `applyPresenterCommandToMaster` (`src/runtime/timeline.ts`) now -// deliver and honor these commands end to end — PUL-F020 / PUL-F021 are -// ACTIVE (issue #132). +// (`src/system/presenter/keyboard-source.ts`) and the present-mode +// transport seam `applyPresenterCommandToMaster` +// (`src/runtime/presenter-transport.ts`, wired onto the master only when +// `mode=present` forwards a controller) now deliver and honor these +// commands end to end — PUL-F020 / PUL-F021 are ACTIVE (issue #132). // // PUL-F025 (master mute) composes ADR-004 with this same seam by // adding the `toggle-master-mute` kind to the allowlist below. The diff --git a/src/runtime/timeline.ts b/src/runtime/timeline.ts index d830112..5ab6808 100644 --- a/src/runtime/timeline.ts +++ b/src/runtime/timeline.ts @@ -70,6 +70,7 @@ import type { SceneTimelineSegment, } from './composition-resolver'; import { KEBAB_IDENTIFIER_FORM, isKebabIdentifier } from './identifier'; +import { wirePresenterCommands } from './presenter-transport'; type GsapTimeline = InstanceType; @@ -138,6 +139,26 @@ const isGsapTimeline = (value: unknown): value is GsapTimeline => * `composition timeline failed:` envelope and tears every mounted scene * down (ADR-025). */ +/** + * A label's time must be a finite number in `[0, duration]` — a label + * outside the scene's content is not a usable moment. `kind` is the noun + * the error uses (`sentinel label` for runtime sentinels, `beat` for + * authored beats) so both callers share one range check. + */ +function validateLabelTime( + sceneId: string, + kind: string, + label: string, + time: number, + duration: number, +): void { + if (!Number.isFinite(time) || time < 0 || time > duration) { + throw new SceneTimelineLabelError( + `scene "${sceneId}" timeline ${kind} "${label}" is at an invalid time ${time}: a ${kind} time must be a finite number between 0 and the scene timeline duration (${duration}s)`, + ); + } +} + export function assertSceneTimeline( value: unknown, sceneId: string, @@ -156,11 +177,7 @@ export function assertSceneTimeline( // by `MasterTimeline.beats()`. The kebab-case rule is a contract // for beat names only. if (label.startsWith('_')) { - if (!Number.isFinite(time) || time < 0 || time > duration) { - throw new SceneTimelineLabelError( - `scene "${sceneId}" timeline sentinel label "${label}" is at an invalid time ${time}: a label time must be a finite number between 0 and the scene timeline duration (${duration}s)`, - ); - } + validateLabelTime(sceneId, 'sentinel label', label, time, duration); continue; } if (!isKebabIdentifier(label)) { @@ -168,11 +185,7 @@ export function assertSceneTimeline( `scene "${sceneId}" timeline label "${label}" is not a valid beat: beat labels must be lowercase kebab-case identifiers (${KEBAB_IDENTIFIER_FORM})`, ); } - if (!Number.isFinite(time) || time < 0 || time > duration) { - throw new SceneTimelineLabelError( - `scene "${sceneId}" timeline beat "${label}" is at an invalid time ${time}: a beat time must be a finite number between 0 and the scene timeline duration (${duration}s)`, - ); - } + validateLabelTime(sceneId, 'beat', label, time, duration); } } @@ -797,6 +810,23 @@ type MasterRunMode = 'hold' | 'loop' | 'play' | 'scrub'; * → `'scrub'`. * - default: → `'play'`. */ +/** + * The head hints that freeze the master at the addressed beat (or frame + * 0) and never play, in precedence order. Each shares the same + * positioning — `seek(beatLabel ?? 0); pause()` — differing only in the + * run mode it reports, so the branch is table-driven rather than a chain + * of near-identical `if` blocks. `headHold` is NOT in this table: it + * holds at frame 0 unconditionally and must run before the beat lookup + * (`mode=paused` is first-frame inspection, never a beat seek). + */ +const PAUSE_AT_BEAT_HINTS: readonly { + readonly engaged: (opts: CompositionTimelineRunOptions) => boolean; + readonly mode: MasterRunMode; +}[] = [ + { engaged: (o) => o.headScreenshot === 'capture', mode: 'hold' }, + { engaged: (o) => o.headCueGate === 'monotonic-forward', mode: 'scrub' }, +]; + function positionMaster( master: MasterTimeline, headSceneId: string | undefined, @@ -808,15 +838,11 @@ function positionMaster( return 'hold'; } const beatLabel = resolveHeadBeatLabel(master, headSceneId, opts); - if (opts.headScreenshot === 'capture') { + const pauseHint = PAUSE_AT_BEAT_HINTS.find((hint) => hint.engaged(opts)); + if (pauseHint !== undefined) { master.seek(beatLabel ?? 0); master.pause(); - return 'hold'; - } - if (opts.headCueGate === 'monotonic-forward') { - master.seek(beatLabel ?? 0); - master.pause(); - return 'scrub'; + return pauseHint.mode; } if (beatLabel !== undefined) { master.seek(beatLabel); @@ -852,7 +878,14 @@ function buildSegmentLabelMap( return out; } -const segmentIndexAtTime = (segments: readonly MasterSegment[], time: number): number => { +/** + * The 0-based index of the composition segment whose start time the + * `time` playhead has reached, scanning from the tail so the most recent + * boundary wins. `-1` for an empty slice; `0` before the first segment + * start. The present-mode presenter transport (`presenter-transport.ts`) + * uses this to keep its scene cursor in sync with the playhead. + */ +export const segmentIndexAtTime = (segments: readonly MasterSegment[], time: number): number => { if (segments.length === 0) return -1; for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i]; @@ -861,170 +894,6 @@ const segmentIndexAtTime = (segments: readonly MasterSegment[], time: number): n return 0; }; -/** - * Per-activation presenter transport state, held inside the - * {@link createGsapCompositionTimeline} `run` closure (fresh per - * navigation). GSAP exposes only a single `paused()` bit, which is not - * a sufficient state model: a PUL-F020 beat `hold`, a GSAP `addPause` - * advance-gate, and an explicit PUL-F021 `pause` all read as "paused" - * but compose differently. ADR-024 *Cross-command precedence* requires - * the runner to keep them apart; these two flags are that private - * state, scoped to the timeline adapter — not URL, loader, scene, or - * storage state. - */ -interface PresenterTransportState { - /** A PUL-F020 beat `hold` is engaged. */ - held: boolean; - /** A PUL-F021 transport `pause` freeze is engaged. */ - explicitlyPaused: boolean; - /** Current scene segment cursor in the active composition slice. */ - activeSegmentIndex: number; -} - -/** - * Translate a {@link PresenterCommand} into a master-timeline transport - * action. Wired by {@link createGsapCompositionTimeline} so the - * presenter keyboard / cross-window bridge / future remote source all - * drive playback the same way. ADR-024 *Cross-command precedence* is - * the binding contract: - * - * - `pause` — engage the explicit PUL-F021 transport freeze. The - * playhead stops wherever it is. - * - `resume` — release an explicit `pause`. A `resume` with no explicit - * pause in effect is a no-op; a `resume` that lifts a pause engaged - * while a `hold` was active leaves the master held (the snapshotted - * beat-pacing state survives — only the freeze is lifted). - * - `hold` — engage a PUL-F020 beat hold at the current playhead. - * Idempotent: a second `hold` keeps the master held; it is NOT a - * play/pause toggle. - * - `advance` — release a beat hold / `addPause` gate, or (when - * playing) seek forward to the next authored beat label OR next - * segment boundary (whichever is closer). Dropped while explicitly - * paused — a beat-pacing command never unfreezes an explicit pause. - * - `skip-forward` / `skip-backward` — seek to the start of the next / - * previous segment (frame 0 before the first). While explicitly - * paused this seeks the frozen playhead but does not resume. - * - `toggle-master-mute` / `toggle-practice` — audio-owned / L2-owned; - * ignored here. - */ -function presenterAdvance( - master: MasterTimeline, - segments: readonly MasterSegment[], - state: PresenterTransportState, - onSegmentChange: (segment: MasterSegment) => void, -): void { - // ADR-024: a beat-pacing command received while explicitly paused - // MUST NOT resume playback — drop it; the frozen playhead stays put. - if (state.explicitlyPaused) return; - state.held = false; - if (master.isPaused()) { - // Release the beat hold (or a GSAP addPause advance-gate). - master.play(); - return; - } - const now = master.time(); - const beatTimes = master.beats().map((b) => b.time); - const nextBeat = beatTimes.filter((t) => t > now + 0.05).sort((a, b) => a - b)[0]; - const nextSegment = segments.find((s) => s.time > now + 0.05); - if (nextBeat === undefined && nextSegment === undefined) return; - if (nextSegment !== undefined && (nextBeat === undefined || nextSegment.time <= nextBeat)) { - master.seek(nextSegment.time); - state.activeSegmentIndex = nextSegment.index; - onSegmentChange(nextSegment); - return; - } - if (nextBeat !== undefined) { - master.seek(nextBeat); - state.activeSegmentIndex = segmentIndexAtTime(segments, nextBeat); - } -} - -/** - * Shared skip body: seek to `target`, report the new active segment, - * then — unless an explicit PUL-F021 pause is in effect — release any - * beat hold and resume playback. ADR-024 permits skip to move the - * frozen playhead while paused (presenter scrubbing), but it must not - * resume. - */ -function presenterSkip( - master: MasterTimeline, - state: PresenterTransportState, - target: MasterSegment | undefined, - onSegmentChange: (segment: MasterSegment) => void, -): void { - if (target === undefined) return; - master.seek(target.time); - state.activeSegmentIndex = target.index; - onSegmentChange(target); - if (state.explicitlyPaused) return; - state.held = false; - if (master.isPaused()) master.play(); -} - -function presenterSkipForward( - master: MasterTimeline, - segments: readonly MasterSegment[], - state: PresenterTransportState, - onSegmentChange: (segment: MasterSegment) => void, -): void { - state.activeSegmentIndex = segmentIndexAtTime(segments, master.time()); - const targetIndex = Math.min(state.activeSegmentIndex + 1, segments.length - 1); - presenterSkip(master, state, segments[targetIndex], onSegmentChange); -} - -function presenterSkipBackward( - master: MasterTimeline, - segments: readonly MasterSegment[], - state: PresenterTransportState, - onSegmentChange: (segment: MasterSegment) => void, -): void { - state.activeSegmentIndex = segmentIndexAtTime(segments, master.time()); - const targetIndex = Math.max(state.activeSegmentIndex - 1, 0); - presenterSkip(master, state, segments[targetIndex], onSegmentChange); -} - -function applyPresenterCommandToMaster( - master: MasterTimeline, - segments: readonly MasterSegment[], - state: PresenterTransportState, - onSegmentChange: (segment: MasterSegment) => void, - cmd: { readonly kind: string }, -): void { - switch (cmd.kind) { - case 'hold': - // Engage a PUL-F020 beat hold. Idempotent — NOT a toggle. - state.held = true; - master.pause(); - return; - case 'pause': - // Engage the explicit PUL-F021 transport freeze. - state.explicitlyPaused = true; - master.pause(); - return; - case 'resume': - // ADR-024: only `resume` unfreezes an explicit pause; a `resume` - // with no explicit pause in effect is a no-op. - if (!state.explicitlyPaused) return; - state.explicitlyPaused = false; - // Restore the snapshotted beat-pacing state: a `hold` engaged - // before the pause survives the resume — the master stays held. - if (state.held) return; - master.play(); - return; - case 'advance': - presenterAdvance(master, segments, state, onSegmentChange); - return; - case 'skip-forward': - presenterSkipForward(master, segments, state, onSegmentChange); - return; - case 'skip-backward': - presenterSkipBackward(master, segments, state, onSegmentChange); - return; - default: - return; // toggle-master-mute / toggle-practice / unknown — not master's concern - } -} - /** * Run the positioned master and resolve when the composition activation * is done — the master reached its natural end (so the resolver tears @@ -1094,22 +963,42 @@ function runMasterUntilDone( }); } +/** + * The per-activation observability seam: the single sink the run loop + * (and the composer's segment-start callbacks, and the present-mode + * transport) push active-segment changes through. `report` de-duplicates + * consecutive identical segments so a GSAP segment-start callback that + * lands on the segment a direct seek already reported does not double- + * fire; `reportInitial` seeds it from the positioned playhead. + */ +interface SegmentReporter { + report(segment: MasterSegment): void; + reportInitial(master: MasterTimeline, segmentAnchors: readonly MasterSegment[]): void; +} + const createSegmentReporter = ( onSegmentChange: ((segment: MasterSegment) => void) | undefined, -): ((segment: MasterSegment) => void) => { +): SegmentReporter => { let lastReportedSegment: string | null = null; - return (segment) => { + const report = (segment: MasterSegment): void => { const key = `${segment.label}@${segment.index}`; if (key === lastReportedSegment) return; lastReportedSegment = key; onSegmentChange?.(segment); }; + return { + report, + reportInitial(master, segmentAnchors) { + const initial = segmentAnchors[segmentIndexAtTime(segmentAnchors, master.time())]; + if (initial !== undefined) report(initial); + }, + }; }; const buildRunComposeOptions = ( opts: CompositionTimelineRunOptions, options: GsapCompositionTimelineOptions, - reportSegmentChange: (segment: MasterSegment) => void, + reporter: SegmentReporter, ): ComposeMasterTimelineOptions => { const composeOpts: ComposeMasterTimelineOptions = {}; if (options.transitions !== undefined) { @@ -1120,8 +1009,9 @@ const buildRunComposeOptions = ( options.transitionOverlay; } if (options.onSegmentChange !== undefined) { - (composeOpts as { onSegmentStart?: (segment: MasterSegment) => void }).onSegmentStart = - reportSegmentChange; + (composeOpts as { onSegmentStart?: (segment: MasterSegment) => void }).onSegmentStart = ( + segment, + ) => reporter.report(segment); } if (opts.headCueGate === 'monotonic-forward' && opts.audioCueGate !== undefined) { (composeOpts as { audioCueGate?: CueGateControl }).audioCueGate = opts.audioCueGate; @@ -1129,32 +1019,6 @@ const buildRunComposeOptions = ( return composeOpts; }; -const reportInitialSegment = ( - master: MasterTimeline, - segmentAnchors: readonly MasterSegment[], - reportSegmentChange: (segment: MasterSegment) => void, -): void => { - const initialSegment = segmentAnchors[segmentIndexAtTime(segmentAnchors, master.time())]; - if (initialSegment !== undefined) reportSegmentChange(initialSegment); -}; - -const wirePresenterCommands = ( - master: MasterTimeline, - segmentAnchors: readonly MasterSegment[], - opts: CompositionTimelineRunOptions, - reportSegmentChange: (segment: MasterSegment) => void, -): void => { - if (opts.presenter === undefined) return; - const transport: PresenterTransportState = { - held: false, - explicitlyPaused: false, - activeSegmentIndex: segmentIndexAtTime(segmentAnchors, master.time()), - }; - opts.presenter.subscribe((cmd) => { - applyPresenterCommandToMaster(master, segmentAnchors, transport, reportSegmentChange, cmd); - }); -}; - /** * Build the GSAP-backed {@link CompositionTimelineAdapter} the * workbench wires onto the composition resolver. Each @@ -1168,13 +1032,15 @@ const wirePresenterCommands = ( * tears every scene down). * * `opts.presenter` (the per-navigation presenter command controller) is - * subscribed here: each {@link PresenterCommand} is translated into a - * master transport action by {@link applyPresenterCommandToMaster} - * (PUL-F020 advance / hold / skip-forward / skip-backward; PUL-F021 - * pause / resume). A segment's `range` (per-entry composition override) - * is still not interpreted — sub-range cuts are PUL-F003's and extend - * this adapter's `MasterTimeline` transport seam when that requirement - * is implemented. + * the opt-in present-mode seam: when supplied, + * {@link import('./presenter-transport').wirePresenterCommands} subscribes + * it and translates each presenter command into a master transport + * action (PUL-F020 advance / hold / skip-forward / skip-backward; + * PUL-F021 pause / resume). A non-present navigation supplies no + * `presenter`, so the transport state machine is never instantiated. A + * segment's `range` (per-entry composition override) is still not + * interpreted — sub-range cuts are PUL-F003's and extend this adapter's + * `MasterTimeline` transport seam when that requirement is implemented. */ export function createGsapCompositionTimeline( options: GsapCompositionTimelineOptions, @@ -1185,13 +1051,13 @@ export function createGsapCompositionTimeline( let master: MasterTimeline; let mode: MasterRunMode; let segmentAnchors: readonly MasterSegment[]; - const reportSegmentChange = createSegmentReporter(onSegmentChange); + const reporter = createSegmentReporter(onSegmentChange); try { - const composeOpts = buildRunComposeOptions(opts, options, reportSegmentChange); + const composeOpts = buildRunComposeOptions(opts, options, reporter); master = composeMasterTimeline(engine, segments, composeOpts); segmentAnchors = buildSegmentLabelMap(segments, master); mode = positionMaster(master, segments[0]?.id, opts); - reportInitialSegment(master, segmentAnchors, reportSegmentChange); + reporter.reportInitial(master, segmentAnchors); } catch (err) { return Promise.reject(err); } @@ -1204,10 +1070,12 @@ export function createGsapCompositionTimeline( return Promise.reject(err); } // Wire the per-navigation presenter controller to master-timeline - // navigation. The controller is signal-bound (per-handler - // auto-detach on navigation abort), so we never accumulate - // subscriptions across activations. - wirePresenterCommands(master, segmentAnchors, opts, reportSegmentChange); + // transport — ONLY when `opts.presenter` is set (present mode). A + // non-present navigation never instantiates the transport state + // machine in `presenter-transport.ts`. The controller is + // signal-bound (per-handler auto-detach on navigation abort), so + // subscriptions never accumulate across activations. + wirePresenterCommands(master, segmentAnchors, opts, (segment) => reporter.report(segment)); return runMasterUntilDone(master, mode, opts.signal); }, }; diff --git a/tests/runtime/timeline.test.ts b/tests/runtime/timeline.test.ts index b068da4..5946f75 100644 --- a/tests/runtime/timeline.test.ts +++ b/tests/runtime/timeline.test.ts @@ -1235,4 +1235,36 @@ describe('createGsapCompositionTimeline — presenter command transport (PUL-F02 r.abort(); await r.settled; }); + + // The transport seam (`presenter-transport.ts`) is opt-in: it is wired + // ONLY when the run input carries a `presenter` controller — i.e. the + // present-mode path. A non-present navigation forwards no controller, + // so the transport state machine is never instantiated and the + // workbench command source is never subscribed. + it('a non-present run (no opts.presenter) never subscribes the presenter source — transport not instantiated', async () => { + const ctrl = new AbortController(); + const subscribe = vi.fn(() => () => undefined); + const source: PresenterCommandSource = { subscribe }; + // The controller exists, but it is NOT forwarded as `opts.presenter` + // (the loader builds one only under mode=present). The transport must + // not be wired, so the source's `subscribe` is never invoked. + createPresenterController(source, ctrl.signal); + const adapter = createGsapCompositionTimeline({ engine }); + await adapter.run([segment('a', sceneTl(1))], { signal: ctrl.signal }); + expect(subscribe).not.toHaveBeenCalled(); + ctrl.abort(); + }); + + it('a present run (opts.presenter set) subscribes the presenter source — transport wired', async () => { + const ctrl = new AbortController(); + const subscribe = vi.fn(() => () => undefined); + const source: PresenterCommandSource = { subscribe }; + const presenter = createPresenterController(source, ctrl.signal); + const adapter = createGsapCompositionTimeline({ engine }); + const settled = adapter.run([segment('a', sceneTl(30))], { signal: ctrl.signal, presenter }); + await flush(); + expect(subscribe).toHaveBeenCalledTimes(1); + ctrl.abort(); + await settled; + }); }); From 976a89261b58100a4362063e1a255cb8dee61326 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 09:37:50 +0200 Subject: [PATCH 05/28] refactor(audio): close audio.ts cognitive-complexity suppressions Decompose the two suppressed audio-engine functions so they fall under the maxAllowedComplexity-15 gate without changing observable behavior: - unlock() delegates its HTML5 and Web Audio fallback branches to the new 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. Remove all three audio.ts rows from the complexity backlog and drop audio.ts from the complexity-gate policy oracle's expected-suppression list (the documented ratchet path). The audio service public methods, output policies, error families, composition bed routing, cue gate, and master-mute semantics are unchanged; the full suite (2669 tests) stays green. --- changelog.d/+audio-engine-slimming.changed.md | 11 ++ docs/design/complexity-backlog.md | 12 +- src/runtime/audio.ts | 161 +++++++++++------- .../policy-biome-complexity-gate.test.ts | 5 +- 4 files changed, 125 insertions(+), 64 deletions(-) create mode 100644 changelog.d/+audio-engine-slimming.changed.md diff --git a/changelog.d/+audio-engine-slimming.changed.md b/changelog.d/+audio-engine-slimming.changed.md new file mode 100644 index 0000000..142494d --- /dev/null +++ b/changelog.d/+audio-engine-slimming.changed.md @@ -0,0 +1,11 @@ +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. diff --git a/docs/design/complexity-backlog.md b/docs/design/complexity-backlog.md index da402a8..a8a67c5 100644 --- a/docs/design/complexity-backlog.md +++ b/docs/design/complexity-backlog.md @@ -50,9 +50,15 @@ 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 | + +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`) diff --git a/src/runtime/audio.ts b/src/runtime/audio.ts index 2493187..ee99ae0 100644 --- a/src/runtime/audio.ts +++ b/src/runtime/audio.ts @@ -172,6 +172,70 @@ export interface AudioEngine { * Howler-backed engine * ------------------------------------------------------------------ */ +/** The slice of Howler's mutable global the unlock dance reads. */ +interface HowlerHandle { + ctx: AudioContext | null | undefined; + noAudio: boolean | undefined; + usingWebAudio: boolean | undefined; +} + +/** 44-byte silent WAV used by both unlock fallback branches. */ +const SILENT_UNLOCK_WAV = + 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA='; + +/** + * HTML5-audio autoplay-policy unlock: `play()` a muted silent `