diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fa105b..99b8105 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,12 +43,12 @@ jobs: run: pnpm install --frozen-lockfile - name: Install pre-commit run: pip install pre-commit - # typecheck and vitest hooks have dedicated jobs below that also - # upload coverage. Skip them here to avoid duplicate runs. + # typecheck, vitest, and policy hooks have dedicated jobs below. + # Skip them here to avoid duplicate runs. - name: Run pre-commit run: pre-commit run --all-files --show-diff-on-failure env: - SKIP: typecheck,vitest + SKIP: typecheck,vitest,policy typecheck: name: Typecheck @@ -89,6 +89,26 @@ jobs: path: coverage/ retention-days: 7 + policy: + # Structural code-shape audits (PUL-Q001/Q003/Q007, PUL-A001..A010, + # complexity-gate allowlist). Relocated out of the behavior suite + # into their own vitest project so their whole-tree AST scans no + # longer starve under parallel load — enforcement is unchanged and + # this step is blocking. Mirrored by the `policy` pre-commit hook. + name: Policy gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + with: + version: 9.15.0 + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm policy + build: name: Build runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3e83a23..20ff685 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,3 +62,14 @@ repos: language: system pass_filenames: false files: ^(src/.*\.ts|tests/.*\.ts|vitest\.config\.ts|package\.json)$ + + # Structural code-shape audits, relocated out of the behavior + # suite into their own vitest project (vitest.policy.config.ts). + # Blocking — same enforcement set as before, just no longer mixed + # into `pnpm test`. Mirrored by the CI `policy` job. + - id: policy + name: pnpm policy (source-policy gate) + entry: bash -c 'pnpm policy' + language: system + pass_filenames: false + files: ^(src/.*\.ts|tests/.*\.ts|biome\.json|vitest\.policy\.config\.ts|package\.json)$ diff --git a/changelog.d/+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/changelog.d/+audio-engine-slimming.changed.md b/changelog.d/+audio-engine-slimming.changed.md new file mode 100644 index 0000000..ac4a6d2 --- /dev/null +++ b/changelog.d/+audio-engine-slimming.changed.md @@ -0,0 +1,28 @@ +Closed the three `src/runtime/audio.ts` cognitive-complexity +suppressions. `unlock()` now delegates its HTML5 and Web Audio +fallback branches to the `unlockHtml5Fallback` / `resumeWebAudioContext` +module helpers; `play()` delegates option validation to `validatePlay` +and per-instance engine output to `applyPlayToHandle`. The +`normalizeSources` offender was already covered by the hoisted +`normalizeAudioUrl`. Behavior is unchanged — the audio service public +methods, output policies, error families, composition bed routing, cue +gate, and master-mute semantics are byte-identical. The audio.ts rows +were removed from `docs/design/complexity-backlog.md` and the +complexity-gate policy oracle. + +Slimmed the audio service internals without changing observable +behavior: the per-service `disposed` boolean and its scattered guards +were replaced by a single internal `AbortController` so the navigation +signal and an explicit `stopAll()` converge on one disposal gate and one +teardown; the composition bed is registered through the same +`registerSound` core scene sounds use under the reserved, non-kebab +`composition audio bed` id (the bespoke `startBed` arrow and `bedAllowed` +local are gone — the bed is now started inline through the shared core, +gated against its own declared `src`), keeping it unreachable from +`ctx.audio`; and the four per-option `assertPlayOption*` helpers were +folded into a table-driven `assertPlayOptions`. The validated +`outputPolicy` string is now collapsed once at construction into the two +orthogonal output axes the policy actually controls — `muted` (engine +mute) and `emitCues` (rehearsal cue sink) — so no service method +re-derives behavior from the policy string. The public `outputPolicy` +option and its allowlist validation are unchanged. diff --git a/changelog.d/+audio-error-and-validator-consolidation.changed.md b/changelog.d/+audio-error-and-validator-consolidation.changed.md new file mode 100644 index 0000000..96242bc --- /dev/null +++ b/changelog.d/+audio-error-and-validator-consolidation.changed.md @@ -0,0 +1,8 @@ +Collapsed the audio error hierarchy and de-duplicated the shape-validator tests. The nine `AudioError` subclasses +(`AudioSoundError` / `AudioGroupError` / `AudioRangeError` / `AudioSourceError`, etc.) are gone — no `src/` caller +discriminated them — replaced by one `AudioError` carrying a `category` discriminant (`sound` / `group` / `source` / +`range` / `option`) and module-private per-category constructors. `ctx.audio` runtime behavior, the throwable surface, +and every rejected input are unchanged. The brittle field-by-field `.each` validation loops for `assertSceneModule`, +`assertCompositionManifest`, and `assertAudioBedDeclaration` are replaced by one representative assert per shape plus a +shared seeded property fuzz (`tests/runtime/validator-fuzz.ts`) covering the same malformed-input classes (omission, +wrong type, out-of-range number, non-kebab id) and asserting the offending field is named. diff --git a/changelog.d/+authoring-real-dom.changed.md b/changelog.d/+authoring-real-dom.changed.md new file mode 100644 index 0000000..9e1ac8b --- /dev/null +++ b/changelog.d/+authoring-real-dom.changed.md @@ -0,0 +1,14 @@ +L2 template/scene authoring now uses real `lib.dom` types. The +structural fake-DOM types (`TemplateDomElement` / `TemplateDomFactory` +/ `TemplateStageElement`) and the per-call defensive ctx narrowing +(`isTemplateCtx` / `isStageShape` / `isGsapShape`) are gone; templates +take `HTMLElement` / `Document` directly and read `ctx` through one +`asTemplateCtx` view that checks only the genuine off-DOM +(`stage === null`) path. Every `as unknown as HTMLElement|Document` +cast in templates and decks is removed. Decks reference a shared +`TemplateTimeline` type instead of re-declaring a structural timeline +subset. The deck-only templates `operatorDossier`, `incidentPlate`, +and `haulCitations` moved into `src/decks/local-calgary-v2/templates/` +(with their CSS) since no other deck uses them. DOM-touching template +tests opt into `happy-dom` per file and assert against real rendering; +the runtime fake-stage suites stay node-env and unchanged. diff --git a/changelog.d/+comment-sweep.changed.md b/changelog.d/+comment-sweep.changed.md new file mode 100644 index 0000000..163aa9b --- /dev/null +++ b/changelog.d/+comment-sweep.changed.md @@ -0,0 +1,8 @@ +Collapsed narrative/ceremony comments across the runtime to terse +one-line contracts. Trimmed module preambles, per-field JSDoc on +`WorkbenchSceneCtx` / `SceneLoaderOptions` / `LoadSceneNavigationTargetOptions`, +and review-cycle narration in `src/main.ts` and `src/runtime/{scene-loader, +scene-loader-ctx,scene-loader-guard,scene,navigation,scene-navigation, +composition-resolver,composition,validation,audio,timeline,presenter, +prompter,workbench-chrome}.ts`. Comments only — no code, behavior, or +public-API change. diff --git a/changelog.d/+composition-resolver-internals.changed.md b/changelog.d/+composition-resolver-internals.changed.md new file mode 100644 index 0000000..da37402 --- /dev/null +++ b/changelog.d/+composition-resolver-internals.changed.md @@ -0,0 +1,23 @@ +Refactored the composition-resolver and scene-navigation internals +without changing behavior. `SceneActivation` identity is now embedded +in each plan step at build time (no per-call reconstruction); the +three finalizers collapse into one `finalize()` that selects +aggregate-vs-reraise from whether `onSceneFailed` was supplied; +`onSceneFailed` is wrapped once at lifecycle-context build; the bare +mount→compose→run→cleanup engine (`runLifecycle`) is separated from +the scene-failure-isolation concern, which now lives in a named +`withFailureIsolation` decorator (owning the failure bucket, the +once-wrapped `onSceneFailed`, and the aggregate-vs-reraise routing) +that `resolveComposition` is composed from; the three composition- +resolution paths (from-start / scene / index) unify behind one +parameterized index finder; and the resolver's run-option builder is +reduced to a single strip. The bare engine (`runLifecycle`, +`orchestrate` via `buildPlan` + `buildLifecycleContext`) is exported so +it can be driven directly in tests with a plain collecting +`reportFailure`. Added both-path (onSceneFailed supplied / omitted) +regression coverage asserting cleanup-exactly-once-per-activation and +correct error routing for create-throw, timeline-throw, cleanup-throw, +abort-mid-mount, and a repeated scene id where one occurrence fails, +plus a bare-engine suite that drives `runLifecycle` without the +decorator. Public signatures, `data-pulsar-*` attributes, and error +wording are unchanged. diff --git a/changelog.d/+mode-profile-table.changed.md b/changelog.d/+mode-profile-table.changed.md new file mode 100644 index 0000000..21bdf26 --- /dev/null +++ b/changelog.d/+mode-profile-table.changed.md @@ -0,0 +1,10 @@ +Centralized per-mode workbench behavior into a single `ModeProfile` +data table (`src/runtime/mode-profile.ts`). The audio output policy, +composition-slice truncation, chrome visibility, audio-bed suppression, +scrub cue gate, and head-scene runner hints were previously scattered +as `mode === X` branches across `scene-loader.ts` and +`workbench-chrome.ts`; they now read one frozen profile per mode. +Behavior is unchanged (snapshot-equivalence test), and collapsing the +four runner-hint ternaries into a single `...runnerHints` spread dropped +`runLifecycle` below the cognitive-complexity gate, closing a +complexity-backlog entry. diff --git a/changelog.d/+policy-gate-relocation.changed.md b/changelog.d/+policy-gate-relocation.changed.md new file mode 100644 index 0000000..c0b1904 --- /dev/null +++ b/changelog.d/+policy-gate-relocation.changed.md @@ -0,0 +1,14 @@ +Relocated the policy / source-scan suites +(`tests/runtime/policy-*.test.ts`, +`tests/runtime/screenshot-determinism-source.test.ts`, and their shared +`source-policy.ts` AST framework) out of the default behavior suite +into a dedicated, still-blocking gate. They run via a new `pnpm policy` +script against `vitest.policy.config.ts` (serial, generous timeout) and +are excluded from `vitest.config.ts`, so `pnpm test` is now +behavior-only and no longer flakes on the structural AST scans starving +under parallel load (PUL-Q003 / PUL-Q007 5s timeouts). Enforcement is +unchanged: `pnpm policy` is wired as a blocking job in +`.github/workflows/ci.yml` and a blocking hook in +`.pre-commit-config.yaml`, running the identical violation set. No +policy check was dropped or weakened; the Biome complexity-gate +override for the cluster is untouched. diff --git a/changelog.d/+presenter-transport-extraction.changed.md b/changelog.d/+presenter-transport-extraction.changed.md new file mode 100644 index 0000000..a9ee3a1 --- /dev/null +++ b/changelog.d/+presenter-transport-extraction.changed.md @@ -0,0 +1,10 @@ +Extracted the presenter transport machinery (advance / hold / skip / +pause / resume command translation) out of the always-on timeline +composition path into a dedicated opt-in module +(`src/runtime/presenter-transport.ts`). The transport is wired onto the +master timeline only when a navigation forwards a presenter controller +(`mode=present`); a non-present navigation never instantiates it. +`timeline.ts` keeps the GSAP composition spine — `composeMasterTimeline`, +the scene label namespace, `assertSceneTimeline`, and the `MasterBeat` +beat query. No public signatures, `data-pulsar-*` attributes, or +cross-engine timing behavior changed. diff --git a/changelog.d/+scene-loader-decomposition.changed.md b/changelog.d/+scene-loader-decomposition.changed.md new file mode 100644 index 0000000..0d227d5 --- /dev/null +++ b/changelog.d/+scene-loader-decomposition.changed.md @@ -0,0 +1,12 @@ +Decomposed the scene-loader god-functions (`buildLoad`, `runTarget`) +into cohesive single-responsibility units below the cognitive-complexity +gate, deleting both `noExcessiveCognitiveComplexity` suppressions. +Per-navigation audio service / presenter pipe / ctx factory moved to +`src/runtime/scene-loader-ctx.ts`; the present-mode audio unlock-gate +predicate and the composition chrome dispatch policy to +`src/runtime/scene-loader-guard.ts`. The `beat` / `mode` grammar rules +are now sourced from a single `NAVIGATION_GRAMMAR` object in +`src/runtime/navigation.ts`, consumed by both `parseNavigationSearch` +and the loader's defense-in-depth re-check (the forged-target trust +seam is retained). `createSceneLoader`, all exported types, the +`data-pulsar-*` stage attributes, and runtime behavior are unchanged. diff --git a/changelog.d/+simplify-audio-timeline.changed.md b/changelog.d/+simplify-audio-timeline.changed.md new file mode 100644 index 0000000..e6d0cc0 --- /dev/null +++ b/changelog.d/+simplify-audio-timeline.changed.md @@ -0,0 +1,13 @@ +Simplified the audio/timeline runtime cluster without behavior change: +inlined the single-use `validateSpeed` / `validateRepeat` validators +into `GsapMasterTimeline.setSpeed` / `.repeat`, rebuilt +`buildRunComposeOptions` as a single conditional-spread literal instead +of an empty object with four `as`-cast field assignments, and collapsed +the `...(x === undefined ? {} : { x })` idiom to `...(x && { x })` for +the object/boolean-typed `sprite` / `mute` fields in +`createHowlerAudioEngine`. Also trimmed the audio-unlock-dom module +preamble, the orphaned/duplicated adapter JSDoc, and the codex-cycle +narration to terse contracts. Public signatures (`AudioService` / +`AudioError` / `MasterTimeline` / `TimelineEngine` / the audio-unlock +adapter), error strings, `data-pulsar-*` attributes, and the abort-race +isolation guard are unchanged. diff --git a/changelog.d/+simplify-runtime-patterns.changed.md b/changelog.d/+simplify-runtime-patterns.changed.md new file mode 100644 index 0000000..63de4ab --- /dev/null +++ b/changelog.d/+simplify-runtime-patterns.changed.md @@ -0,0 +1,11 @@ +Simplified the scene-loader and navigation/composition runtime cluster +without behavior change: inlined linter-noise micro-helpers +(`setStageAttr` / `clearStageAttr` / `audioOutputPolicyFor` / +`audioServiceOptions`), collapsed the `...(x === undefined ? {} : { x })` +conditional-spread idiom to its positive `...(x ? { x } : {})` form, +replaced writable-intermediate-then-freeze object construction in +`parseNavigationSearch` / `composeSegments` / `buildPrompterScript` with +direct frozen literals, and merged the two-stage `chromeBehavior` +extraction in `scene-loader-guard`. Public signatures, `data-pulsar-*` +attributes, error strings, and PUL-Q008 attribute-literal handling are +unchanged. diff --git a/changelog.d/+simplify-system-bootstrap.changed.md b/changelog.d/+simplify-system-bootstrap.changed.md new file mode 100644 index 0000000..d9715b0 --- /dev/null +++ b/changelog.d/+simplify-system-bootstrap.changed.md @@ -0,0 +1,8 @@ +Made `createDomWorkbenchChrome` and `createDomAudioUnlockAdapter` generic +over their concrete element type so `src/main.ts` mounts a real +`HTMLElement` / `HTMLButtonElement` without `as unknown as Node` casts. +Trimmed narrative/ceremony comments in the chrome, prompter-window, +practice-renderer, and keyboard-source modules, and collapsed the +repeated `tl.fromTo` reveal boilerplate in the pulsar-intro deck behind a +local `reveal` helper (byte-identical timeline output). Behavior, exported +signatures, DOM attributes, and tests are unchanged. diff --git a/changelog.d/+template-coverage.added.md b/changelog.d/+template-coverage.added.md new file mode 100644 index 0000000..92ecccf --- /dev/null +++ b/changelog.d/+template-coverage.added.md @@ -0,0 +1,11 @@ +Behavioral test coverage for the under-tested L2 template library and +runtime timing helpers. New mount-and-assert suites exercise the real +rendered DOM, authored timeline beats, and cleanup for `terminal`, +`card-carousel`, `activity-feed-payoff`, `chat-pick-list`, +`split-dialogue-email`, `split-pane-terminal-doc`, `metric-ticker`, the +`_shared` template envelope, the `register` token barrel, and the +abortable-timing primitives in `helpers/timing`. Product line coverage +(runtime + template library) rises from ~93% to ~99%; `terminal.ts` +alone goes 35% to 99%. The example decks (demonstration content, +exercised by the Playwright E2E) are scoped out of the coverage gate — +the gate measures the product, not the sample decks. diff --git a/changelog.d/+test-fakes-fixture.changed.md b/changelog.d/+test-fakes-fixture.changed.md new file mode 100644 index 0000000..78d7186 --- /dev/null +++ b/changelog.d/+test-fakes-fixture.changed.md @@ -0,0 +1,11 @@ +Extracted the duplicated jsdom-free test fakes into a single +`tests/support/fakes.ts` fixture: the `mode=*` fixture-scene stage stub +(was re-derived byte-for-byte in five `tests/scenes/*-fixture.test.ts` +files), the synthetic `HTMLElement`/`Document` tree the chrome pack +tests use (was duplicated across `chrome-slots` and `chrome-extras`), +and the event-emitting / no-op presenter controllers (was re-rolled in +`helpers` and `presenter-driven`). Typing the chrome fake as the real +DOM interfaces dropped every `as unknown as HTMLElement|FakeElement` +cast at the chrome-test call sites, and trimmed the copy-pasted +coverage-narration headers to one line each. Assertions, test counts, +and behavior coverage are unchanged. diff --git a/changelog.d/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/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/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/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/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/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/complexity-backlog.md b/docs/design/complexity-backlog.md index a175b60..e965282 100644 --- a/docs/design/complexity-backlog.md +++ b/docs/design/complexity-backlog.md @@ -50,12 +50,33 @@ function per site. | File | Symbol | Score | |------|--------|-------| | [`src/runtime/asset-preloader.ts`](../../src/runtime/asset-preloader.ts) | returned async `(scene) => ...` arrow inside `createAssetPreloader` | 16 | -| [`src/runtime/audio.ts`](../../src/runtime/audio.ts) | `async unlock()` method on the AudioUnlocker | 22 | -| [`src/runtime/audio.ts`](../../src/runtime/audio.ts) | `normalizeSources` arrow | 17 | -| [`src/runtime/audio.ts`](../../src/runtime/audio.ts) | `play(soundId, options)` method | 24 | -| [`src/runtime/scene-loader.ts`](../../src/runtime/scene-loader.ts) | `buildLoad` arrow | 18 | -| [`src/runtime/scene-loader.ts`](../../src/runtime/scene-loader.ts) | `runLifecycle` arrow | 21 | -| [`src/runtime/scene-loader.ts`](../../src/runtime/scene-loader.ts) | `runTarget` async arrow | 23 | + +The three `src/runtime/audio.ts` offenders — `async unlock()` (22), +`normalizeSources` (17), and `play(soundId, options)` (24) — were +removed when the audio engine was slimmed: `unlock()` delegates to the +`unlockHtml5Fallback` / `resumeWebAudioContext` module helpers, the +per-URL validation lives in the hoisted `normalizeAudioUrl`, and +`play()` delegates option validation to `validatePlay` and engine +output to `applyPlayToHandle`. Their site-level suppressions were +deleted with them. + +`runLifecycle` (formerly score 21) was removed from this list when the +per-mode runner hints (`repeat` / `hold` / `cueGate` / `screenshot`) +collapsed from four `mode === X` ternaries into a single +`...runnerHints` spread sourced from +[`src/runtime/mode-profile.ts`](../../src/runtime/mode-profile.ts). Its +site-level suppression was deleted with it. + +`buildLoad` (score 18) and `runTarget` (score 23) were removed when the +scene loader was decomposed into cohesive single-responsibility units: +per-navigation audio/presenter/ctx construction moved to +[`src/runtime/scene-loader-ctx.ts`](../../src/runtime/scene-loader-ctx.ts), +the unlock-gate predicate and chrome dispatch policy to +[`src/runtime/scene-loader-guard.ts`](../../src/runtime/scene-loader-guard.ts), +and the `beat` / `mode` grammar re-check unified onto the shared +`NAVIGATION_GRAMMAR` source in +[`src/runtime/navigation.ts`](../../src/runtime/navigation.ts). Both +site-level suppressions were deleted with them. ### Test fixtures and helpers @@ -90,6 +111,17 @@ targets at all, by design. The override allowlist is enforced by `tests/runtime/policy-biome-complexity-gate.test.ts` so the cluster cannot be quietly widened. +This policy-scanner cluster (`policy-*.test.ts`, `source-policy.ts`, +`screenshot-determinism-source.test.ts`) does **not** run in the +default behavior suite (`pnpm test`). It is a separate, still-blocking +gate run via `pnpm policy` (`vitest.policy.config.ts`), wired into both +CI (the `policy` job) and the `policy` pre-commit hook. Its whole-tree +AST scans starved under the behavior suite's parallel load and +intermittently timed out, so the gate was relocated — the enforcement +set is unchanged. The Biome override above stays regardless of where +the suite runs; `biome.json` remains the canonical complexity-gate +declaration. + ## Ratchet plan The intent of the gate is to lower `maxAllowedComplexity` as the diff --git a/docs/design/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/package.json b/package.json index 0d38025..3b868b0 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build": "vite build", "preview": "vite preview", "test": "vitest run", + "policy": "vitest run --config vitest.policy.config.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:browsers": "playwright test", @@ -28,12 +29,18 @@ "@types/howler": "^2.2.12", "@types/node": "^22.18.0", "@vitest/coverage-v8": "^3.0.0", + "happy-dom": "^20.9.0", "typescript": "^5.7.2", "vite": "^6.0.0", "vitest": "^3.0.0", "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 0000000..e89b406 Binary files /dev/null and b/pi-after-end.png differ diff --git a/pi-outro-end.png b/pi-outro-end.png new file mode 100644 index 0000000..e89b406 Binary files /dev/null and b/pi-outro-end.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73a6c95..aa8426d 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 @@ -32,7 +47,10 @@ importers: version: 22.19.19 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(yaml@2.9.0)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(happy-dom@20.9.0)(yaml@2.9.0)) + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -41,7 +59,7 @@ importers: version: 6.4.2(@types/node@22.19.19)(yaml@2.9.0) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.19)(yaml@2.9.0) + version: 3.2.4(@types/node@22.19.19)(happy-dom@20.9.0)(yaml@2.9.0) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -282,6 +300,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'} @@ -452,6 +485,12 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -572,6 +611,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -618,6 +661,10 @@ packages: gsap@3.15.0: resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -900,6 +947,10 @@ packages: jsdom: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -918,6 +969,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -1058,6 +1121,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 @@ -1180,7 +1253,13 @@ snapshots: dependencies: undici-types: 6.21.0 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.19)(yaml@2.9.0))': + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.19 + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.19)(happy-dom@20.9.0)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -1195,7 +1274,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.19.19)(yaml@2.9.0) + vitest: 3.2.4(@types/node@22.19.19)(happy-dom@20.9.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -1307,6 +1386,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@7.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.25.12: @@ -1370,6 +1451,18 @@ snapshots: gsap@3.15.0: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 22.19.19 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} howler@2.2.4: {} @@ -1610,7 +1703,7 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 - vitest@3.2.4(@types/node@22.19.19)(yaml@2.9.0): + vitest@3.2.4(@types/node@22.19.19)(happy-dom@20.9.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -1637,6 +1730,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 + happy-dom: 20.9.0 transitivePeerDependencies: - jiti - less @@ -1651,6 +1745,8 @@ snapshots: - tsx - yaml + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -1672,4 +1768,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + ws@8.21.0: {} + yaml@2.9.0: {} diff --git a/sonar-project.properties b/sonar-project.properties index d4a5d63..2d44176 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -17,11 +17,25 @@ sonar.exclusions=\ **/*.min.js,\ assets/** -# Coverage exclusions: workbench entry and Vite/Vitest configs are not -# part of the runtime surface measured by tests. +# Coverage exclusions: the workbench entry, Vite/Vitest configs, and the +# example decks are not part of the product surface the coverage gate +# measures. Pulsar's product is the runtime + reusable template library +# ("the runtime is the product, not a specific deck"); the decks are +# demonstration content, exercised end-to-end by the Playwright specs +# (tests-e2e/pulsar-intro.spec.ts, aces-ecosystem-navigation.spec.ts). sonar.coverage.exclusions=\ **/main.ts,\ - **/*.config.ts + **/*.config.ts,\ + src/decks/** + +# Duplication exclusions: the example decks are demonstration content +# whose scenes share an intentionally-repetitive build/beats scaffold +# (a deck is many similar scenes by design). That is not product +# duplication — the same "the runtime is the product, not a specific +# deck" rationale as the coverage exclusion above. The product surface +# (runtime + template library) carries negligible duplication. +sonar.cpd.exclusions=\ + src/decks/** # TypeScript coverage report sonar.typescript.lcov.reportPaths=coverage/lcov.info 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..58bc310 --- /dev/null +++ b/src/decks/aces-ecosystem-intro/content.ts @@ -0,0 +1,1485 @@ +// 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 { + type TemplateTimeline, + buildTemplateScene, + buildTemplateTimeline, + cleanupTemplateRoot, + mountTemplateRoot, +} from '../../system/templates/_shared'; + +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: TemplateTimeline, rootValue: string) => void; + /** + * Extend the trailing tween to an indefinite hold so the master never + * ends and `skip-backward` keeps working past the deck's last scene. + * Only the final scene sets it. + */ + 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 d = ownerDoc; + 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); + root.appendChild(page); + }, + }); + }, + timeline: (ctx) => + buildTemplateTimeline({ + ctx, + rootValue: spec.id, + suffixDurationSeconds: spec.holdForever === true ? 3600 : 0.6, + buildSegments: (tl) => { + spec.beats(tl, spec.id); + }, + }), + cleanup: cleanupTemplateRoot(spec.id), + }); + +// Every beat is the same restrained reveal: fade up from a small offset. +const fadeIn = ( + tl: TemplateTimeline, + 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 ?? 0, + 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..8ebedaf 100644 --- a/src/decks/pulsar-intro/content.ts +++ b/src/decks/pulsar-intro/content.ts @@ -1,175 +1,902 @@ -// 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'; + type TemplateTimeline, + 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 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: TemplateTimeline, rootValue: string) => void; + /** + * Extend the trailing tween to an indefinite hold so the master never + * ends and `skip-backward` keeps working past the deck's last scene. + * Only the final scene sets it. + */ + readonly holdForever?: boolean; +} + +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, ownerDoc); + appendFolio(root, ownerDoc, spec); + }, + }); + }, + timeline: (ctx) => + buildTemplateTimeline({ + ctx, + rootValue: spec.id, + // 3600s ≈ "until the user navigates away" (see holdForever). + suffixDurationSeconds: spec.holdForever === true ? 3600 : 0.8, + buildSegments: (tl) => { + spec.beats(tl, 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}`; + +// Every beat is the same reveal: fade up from a small offset on `expo.out`. +// `y`/`x` pick the offset axis (omit both for an opacity-only fade); `stagger` +// is included only when given so multi-element selectors cascade. +const reveal = ( + tl: TemplateTimeline, + id: string, + cls: string, + at: number, + opts: { duration: number; y?: number; x?: number; stagger?: number }, +): void => { + const offset = opts.x !== undefined ? { x: opts.x } : opts.y !== undefined ? { y: opts.y } : {}; + const land = opts.x !== undefined ? { x: 0 } : opts.y !== undefined ? { y: 0 } : {}; + const stagger = opts.stagger === undefined ? {} : { stagger: opts.stagger }; + tl.fromTo( + sel(id, cls), + { opacity: 0, ...offset }, + { opacity: 1, ...land, duration: opts.duration, ...stagger, ease: 'expo.out' }, + at, + ); +}; + +// ---------------------------------------------------------------------- +// 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); + reveal(tl, id, '.pi-title__a', 0, { y: 22, duration: 0.6, stagger: 0.035 }); + reveal(tl, id, '.pi-title__dot', 0.32, { y: 22, duration: 0.6 }); + tl.addLabel('subtitle-in', 0.9); + reveal(tl, id, '.pi-subtitle', 0.9, { y: 10, duration: 0.5 }); + 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); + reveal(tl, id, '.pi-display__w', 0, { y: 18, duration: 0.7, stagger: 0.06 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('body-in', 0.6); + reveal(tl, id, '.pi-body', 0.6, { y: 10, duration: 0.5 }); + tl.addLabel('shape-in', 1.0); + reveal(tl, id, '.pi-code', 1.0, { y: 12, duration: 0.6 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('body-in', 0.6); + reveal(tl, id, '.pi-body', 0.6, { y: 10, duration: 0.5 }); + tl.addLabel('shape-in', 1.0); + reveal(tl, id, '.pi-code', 1.0, { y: 12, duration: 0.6 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('body-in', 0.6); + reveal(tl, id, '.pi-body', 0.6, { y: 10, duration: 0.5 }); + tl.addLabel('cols-in', 1.0); + reveal(tl, id, '.pi-tri__col', 1.0, { y: 18, duration: 0.7, stagger: 0.18 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('rows-in', 0.7); + reveal(tl, id, '.pi-modes__url', 0.7, { x: -8, duration: 0.4, stagger: 0.09 }); + reveal(tl, id, '.pi-modes__desc', 0.78, { duration: 0.4, stagger: 0.09 }); + 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); + reveal(tl, id, '.pi-display__w', 0, { y: 16, duration: 0.6, stagger: 0.06 }); + tl.addLabel('url-in', 0.9); + reveal(tl, id, '.pi-body', 0.9, { y: 10, duration: 0.5 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('rows-in', 0.7); + reveal(tl, id, '.pi-modes__url', 0.7, { x: -8, duration: 0.4, stagger: 0.09 }); + reveal(tl, id, '.pi-modes__desc', 0.78, { duration: 0.4, stagger: 0.09 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('layers-in', 0.7); + reveal(tl, id, '.pi-layer', 0.7, { y: 18, duration: 0.6, stagger: 0.16 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('body-in', 0.6); + reveal(tl, id, '.pi-body', 0.6, { y: 10, duration: 0.5 }); + tl.addLabel('shape-in', 1.0); + reveal(tl, id, '.pi-code', 1.0, { y: 12, duration: 0.6 }); + 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); + reveal(tl, id, '.pi-display__w', 0, { y: 16, duration: 0.6, stagger: 0.07 }); + tl.addLabel('sub-in', 1.0); + reveal(tl, id, '.pi-body', 1.0, { y: 10, duration: 0.5 }); + 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); + reveal(tl, id, '.pi-eyebrow', 0, { y: 6, duration: 0.4 }); + tl.addLabel('heading-in', 0.25); + reveal(tl, id, '.pi-heading', 0.25, { y: 12, duration: 0.5 }); + tl.addLabel('rows-in', 0.7); + reveal(tl, id, '.pi-try', 0.7, { x: -8, duration: 0.45, stagger: 0.13 }); + 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 6344af9..1f8e8b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,28 +1,9 @@ -// Workbench entry — bootstraps Pulsar's runtime and activates URL -// navigation per PUL-F007 (URL grammar parser) + PUL-F008 (scene -// navigation dispatch) + ADR-007 + ADR-013 + ADR-014. -// -// Lifecycle: -// -// 1. Mark the `#stage` element so the placeholder background is -// visible while the runtime decides what (if anything) to load. -// 2. Build the scene and composition registries from the bundled -// scene/composition modules. Both registries are immutable after -// construction (ADR-008 #2 "manifests over flow control"). -// 3. Build a `SceneLoader` (PUL-F008) wired to those registries plus -// the lifecycle adapters: PUL-F005 asset preloader, the ADR-003 / -// PUL-F022 GSAP-backed composition timeline, the PUL-F024 / ADR-004 -// audio service, and the PUL-F030 / ADR-029 audio-unlock adapter. -// 4. Subscribe to the parsed-target events PUL-F007's -// `bootstrapNavigation` dispatches: `pulsar:navigate` carries a -// parsed `NavigationTarget`, `pulsar:navigate-error` carries a -// grammar `Error`. Both are translated into loader calls so URL -// parameters are honored at startup and on every `popstate` -// (ADR-007). -// 5. Vite HMR re-evaluating the entry module disposes the previous -// popstate listener AND the loader's in-flight load, so re-eval -// does not stack duplicate listeners or strand a half-loaded -// scene. +// Workbench entry — bootstraps the Pulsar runtime and activates URL +// navigation (PUL-F007 / PUL-F008 / ADR-007 / ADR-013 / ADR-014). Builds +// the immutable registries, wires a `SceneLoader` to the lifecycle +// adapters (preloader, timeline, audio, unlock, chrome), and subscribes +// to `pulsar:navigate` / `pulsar:navigate-error`. Vite HMR disposes the +// popstate listener and any in-flight load on re-eval. import { createAssetPreloader } from './runtime/asset-preloader'; import { type AudioService, createHowlerAudioEngine } from './runtime/audio'; @@ -38,7 +19,11 @@ import { } from './runtime/navigation'; import type { PrompterRenderer } from './runtime/prompter'; import { createSceneRegistry } from './runtime/registry'; -import { type WorkbenchSceneCtx, createSceneLoader } from './runtime/scene-loader'; +import { + type WorkbenchChromeSlots, + type WorkbenchSceneCtx, + createSceneLoader, +} from './runtime/scene-loader'; import { createGsapCompositionTimeline, createTimelineEngine } from './runtime/timeline'; import { assertNoValidationFindings, validateRuntime } from './runtime/validation'; import { createDomWorkbenchChrome } from './runtime/workbench-chrome'; @@ -61,6 +46,7 @@ import { createKeyboardPresenterSource, createPracticeRenderer, createPresenterBridge, + getPresenterSessionId, } from './system/presenter'; import { defaultTransitions } from './system/transitions'; import { WORKBENCH_COMPOSITIONS, WORKBENCH_SCENES } from './workbench-graph'; @@ -68,39 +54,17 @@ import { WORKBENCH_COMPOSITIONS, WORKBENCH_SCENES } from './workbench-graph'; const stage = document.querySelector('#stage'); stage?.setAttribute('data-pulsar', 'placeholder'); -// PUL-F028 / ADR-008 #7: structural validation pass BEFORE any -// lifecycle effect. Runs over the same declarative inputs the -// workbench is about to hand to `createSceneRegistry` and -// `createCompositionRegistry`, surfacing every finding — missing -// scenes in compositions, dangling assets, duplicate ids, undeclared -// cleanup — in one aggregate. The workbench error sink picks up the -// per-finding detail; the `data-pulsar-validation-failed` stage -// attribute lets screenshot regression and agent-driven inspection -// see at a glance that boot was aborted on structural grounds. The -// throwing wrapper halts the workbench so navigation, the loader, -// the preloader, and the resolver never touch a broken graph. Empty -// findings array → boot proceeds untouched. -// -// `scenes` and `compositionEntries` are declared ONCE in -// `./workbench-graph.ts` and passed to both the validator and the -// registry constructors. Splitting them into per-call literals -// would let a future scene get added to the registry path without -// being added to validation, leaving the PUL-F028 gate looking -// active while running unvalidated inputs (codex review cycle 3). -// The canonical module is the same source the PUL-P002 CI gate -// (`tests/runtime/workbench-graph.test.ts`) consumes — the browser -// bootstrap and the CI validation run against identical inputs. -// The module lives at the composition-root layer (next to -// `main.ts`), not under `src/runtime/`, so the reusable runtime -// engine does not import concrete scenes or compositions. +// PUL-F028 / ADR-008 #7: structural validation BEFORE any lifecycle +// effect, over the same inputs the registries get. On findings, mark +// `data-pulsar-validation-failed` and throw, so the loader/resolver +// never touch a broken graph. The single shared +// `WORKBENCH_SCENES` / `WORKBENCH_COMPOSITIONS` source (also the +// PUL-P002 CI gate's input) keeps validated and registered inputs identical. const scenes = WORKBENCH_SCENES; const compositionEntries = WORKBENCH_COMPOSITIONS; -// Clear the failure marker before every boot. In a same-document -// lifecycle (Vite HMR or repeated `import` evaluation) a previous -// failed evaluation may have set the attribute; without an explicit -// reset, the stage would stay marked as validation-failed even after -// the author fixes the broken graph (codex review cycle 3). +// Clear the failure marker before every boot so a fixed graph is not left +// marked validation-failed across a same-document re-eval (Vite HMR). stage?.removeAttribute('data-pulsar-validation-failed'); const validationFindings = validateRuntime({ scenes, compositions: compositionEntries }); @@ -115,52 +79,24 @@ if (validationFindings.length > 0) { const sceneRegistry = createSceneRegistry(scenes); const compositionRegistry = createCompositionRegistry(compositionEntries); -// PUL-F022 / ADR-003: the GSAP timeline engine. Scenes receive it as -// `ctx.gsap` and build their timeline with it; the runtime composes the -// scene timelines into a master timeline (see `./runtime/timeline.ts`). +// PUL-F022 / ADR-003: GSAP timeline engine, threaded to scenes as `ctx.gsap`. const timelineEngine = createTimelineEngine(); -// PUL-F024 / ADR-004: the Howler audio engine — a process singleton. -// The loader builds a fresh per-navigation `AudioService` over it -// (scoped to the navigation's `AbortSignal`) and threads it into -// `ctx.audio`; scenes call `ctx.audio.play(...)` / `fade(...)` etc. -// rather than importing Howler. Howler auto-handles the browser -// autoplay-unlock gesture, so the workbench does not. +// PUL-F024 / ADR-004: Howler audio engine (process singleton). The loader +// builds a per-navigation `AudioService` over it for `ctx.audio`. const audioEngine = createHowlerAudioEngine(); -// PUL-F005 asset preloader. The placeholder scene declares no assets, -// so the preloader is a structural no-op today; once scenes start -// declaring URLs the same wire-up validates schemes, fetches, and -// stream-drains them ahead of `create(ctx)`. The factory shape lets -// the loader build a fresh preloader per navigation with an -// `AbortSignal` that cancels the in-flight `fetch` calls when the -// user clicks back/forward mid-preload. +// PUL-F005 asset preloader factory — one per navigation, bound to an +// `AbortSignal` so back/forward cancels in-flight fetches. const createPreloader = (signal: AbortSignal): ReturnType => createAssetPreloader({ init: { signal } }); -// Timeline adapter — PUL-F022 / ADR-003 / ADR-025. The GSAP-backed -// composition timeline: the resolver mounts every scene in the active -// composition slice (preload + `create`), hands the adapter their -// `timeline(ctx)` values, and `run` composes them into one master GSAP -// timeline (`composeMasterTimeline` — namespaced labels, sequential -// nesting), applies the URL/runner-input head hints (`beat` seek with -// the `onBeatMissing` fallback, `mode=loop` repeat, `mode=paused` hold, -// `mode=screenshot` freeze-at-beat), wires the PUL-F024 audio engine -// and the PUL-F017 / ADR-020 cue gate (closed on reverse scrub), -// subscribes the per-navigation presenter command controller -// (PUL-F020 advance / hold / skip-forward / skip-backward + PUL-F021 -// pause / resume), plays the master, and resolves on its natural -// completion (the resolver then tears every scene down) or on the -// per-navigation `AbortSignal`. Per-entry `range` overrides (PUL-F003) -// are not interpreted — sub-range cuts extend this adapter's -// `MasterTimeline` transport seam when that requirement lands. -// Inter-scene transition overlay — a transient `
` parented to -// `document.body` (above the chrome surface). The L2 transitions -// library (cut / dissolve / hard-slam / hold-on-black / push) tweens -// this element via the master timeline; it lives outside the scene -// roots so a transition can never desynchronize scene-owned GSAP -// state. Created here so it survives across navigations within a -// composition. +// Timeline adapter — PUL-F022 / ADR-003 / ADR-025: composes scene timelines +// into the master, applies URL head hints, wires audio + cue gate + +// presenter, and resolves on natural completion or abort. +// Inter-scene transition overlay (`
` on `document.body`, above the +// chrome). L2 transitions tween it via the master timeline; living outside +// the scene roots keeps it from desyncing scene-owned GSAP state. const transitionOverlay = document.createElement('div'); transitionOverlay.dataset.pulsarTransition = 'overlay'; transitionOverlay.style.position = 'fixed'; @@ -171,48 +107,42 @@ transitionOverlay.style.opacity = '0'; transitionOverlay.style.display = 'none'; document.body.appendChild(transitionOverlay); -// PUL-F017 / ADR-020: the workbench scrub controls. Built after the -// chrome surface is mounted (below); declared here so the timeline -// adapter's `onMaster` hook can attach the live master to them. The -// controls are revealed only under `mode=scrub` — `scrubMode` is -// re-derived from the URL on every navigation (`onNavigate`), so a -// non-scrub navigation never surfaces the transport bar. +// PUL-F017 / ADR-020: scrub controls (built below). Declared here so the +// timeline adapter's `onMaster` hook can attach the live master; revealed +// only under `mode=scrub` (re-derived per navigation in `onNavigate`). 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, - // 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 - // ignores the hook. + onSegmentChange: applyActiveSegment, + // Reports the live master once per activation; only `mode=scrub` + // attaches it to the scrub controls (PUL-F017 / ADR-020). onMaster: (master) => { if (scrubMode) scrubControls?.attach(master); }, }); -// Scene context carries the stage handle, the per-navigation effective -// workbench mode (PUL-F012 / ADR-007), the GSAP instance scenes build -// their timeline with (PUL-F022 / ADR-003), and the per-navigation -// audio service (PUL-F024 / ADR-004). The loader calls this builder -// once per navigation that produces a runnable target, passing the -// effective mode it derived from the URL via `effectiveMode` and the -// audio service it built over `audioEngine`. Constructing ctx per -// navigation enforces ADR-007's "URL is the only source of mode" rule -// by construction — there is no long-lived ctx slot for a previous -// mode to linger in — and makes "audio survives the scene that started -// it" impossible. ADR-008 #2 (explicit dependencies over ambient -// globals) is satisfied by passing `stage`, `gsap`, and `audio` through -// ctx rather than reaching for `document`, importing GSAP, or importing -// Howler directly in scene modules. // Filled in after the chrome surface is mounted (below). let chromeSlots: ChromeSlots | undefined; -// Builds the navigation-scoped scene ctx; the loader adds each -// occurrence's `activation` (issue #99) and its seeded `rng` (PUL-F018 -// / ADR-021), so the return type omits both. +// Build the navigation-scoped scene ctx; the loader adds each occurrence's +// `activation` (issue #99) and seeded `rng` (PUL-F018), so both are omitted. const buildCtx = ( mode: NavigationMode, audio: AudioService, @@ -226,61 +156,29 @@ const buildCtx = ( ...(presenter === undefined ? {} : { presenter }), }; if (chromeSlots === undefined) return base; - return { ...base, chrome: chromeSlots as unknown as Readonly> }; + // L2 `ChromeSlots` → the L1-agnostic ctx slot (named-prop shape vs. index type). + return { ...base, chrome: chromeSlots as unknown as WorkbenchChromeSlots }; }; -// Prompter renderer (PUL-F019 / ADR-022). Under `mode=prompter` the -// loader bypasses the resolver lifecycle structurally — no preload, -// no `create`, no `timeline`, no `cleanup` — and hands a -// `PrompterScript` (captions aggregated from the addressed scene or -// composition slice) to this adapter. The L2 -// `createChromePrompterRenderer` paints the full script (composition -// id + per-scene captions) into the chrome lower-third slot, falling -// back to `document.body` when the slot is unavailable. The returned -// dispose callback removes the panel on the next navigation. +// Prompter renderer (PUL-F019 / ADR-022): paints the captions script into +// the chrome lower-third slot (falling back to `document.body`), returning +// a dispose callback the loader runs on the next navigation. const renderPrompter: PrompterRenderer = createChromePrompterRenderer( () => chromeSlots?.lowerThird ?? document.body, ); -// Presenter command source (PUL-F013 / PUL-F020 / PUL-F021 / -// ADR-023 / ADR-024): present-mode presenter input is wired in this -// composition root. `createKeyboardPresenterSource()` (arrows / -// Space / PageUp / PageDown / P / K / L / M / N / Escape) and -// `createPresenterBridge()` (same-origin -// cross-window `BroadcastChannel`) are constructed below and merged -// by `combinePresenterSources()` into the single -// `PresenterCommandSource` handed to `createSceneLoader` as -// `presenterCommands`. The loader builds a per-navigation -// `PresenterController` when `mode=present`, threads it into -// `ctx.presenter` and the timeline adapter, and aborts the -// subscription on navigation abort / completion. The command kinds -// are `advance` / `hold` / `skip-forward` / `skip-backward` -// (PUL-F020) plus the `pause` / `resume` transport-freeze gate -// (PUL-F021, ADR-024) — the runner honors the latter with -// playhead-preserving precedence over the beat-pacing kinds. A -// future remote presenter source must authenticate before emitting -// into `PresenterCommandSource`; the controller stays the local -// command-shape gate. -// PUL-F030 / ADR-029: present-mode audio unlock adapter. The loader -// invokes this BEFORE preload + scene `create(ctx)` + scene -// `timeline(ctx)` + master timeline playback when a present-mode -// composition declares audio (any scene's `scene.audio` non-empty). -// The factory in `./runtime/audio-unlock-dom` owns the click / abort / -// cleanup contract; this wiring just supplies the stage and the -// concrete ``; tests build a - * minimal fake with an `addEventListener` / `remove` shape. + * Inputs to {@link createDomAudioUnlockAdapter}, generic over the concrete + * button type so production passes a real `HTMLButtonElement` (no cast). + * A `null` mount rejects at navigation time — the gate IS the structural + * defense for PUL-F030, so an inert seam would silently violate it. */ -export interface DomAudioUnlockHost { - readonly mount: UnlockMount | null; - readonly createButton: () => UnlockButtonElement; +export interface DomAudioUnlockHost { + readonly mount: UnlockMount | null; + readonly createButton: () => B; } /** - * Build a {@link AudioUnlockAdapter} that collects an explicit user - * click through the workbench DOM, satisfies the autoplay policy via - * `gate.unlock()`, and resolves so the present-mode composition can - * proceed. - * - * Contract (verified by `tests/runtime/audio-unlock-dom.test.ts`): - * - * - `mount === null` → reject immediately with a clear error. - * - Signal already aborted on entry → reject immediately, no button - * mounted. - * - Otherwise → mount the button, install one click listener - * (`{ once: true }`) and one signal-abort listener. On click, - * call `gate.unlock()`; on the unlock's resolve, remove the - * button and resolve the adapter; on the unlock's reject or on - * a signal-abort, remove the button and reject the adapter. - * - The `settled` flag is set only AFTER `gate.unlock()` settles, - * so a supersession during `AudioContext.resume()` propagates - * promptly (cycle-1 review fix). The unlock then-callback bails - * on `settled` if abort ran first. - * - The adapter never receives raw scene objects, source URLs, or - * Howler handles — only the bounded {@link AudioUnlockContext}. - */ -/** - * Per-invocation state the gate keeps so the click/abort race stays - * coherent across the async `gate.unlock()` await. Hoisted to module - * scope so the gate adapter's nested-function depth stays under - * Sonar's S2004 4-level limit. + * Per-invocation state the click/abort handlers share across the async + * `gate.unlock()` await: the button, resolvers, listeners, and the + * `settled` once-guard. */ interface GateState { readonly button: UnlockButtonElement; @@ -133,12 +75,9 @@ function onAbortFired(state: GateState): void { state.reject(new Error('audio unlock gate: navigation aborted before unlock completed')); } -// Codex review cycle 1 (one-off "abort after the click no longer -// cancels the unlock adapter"): do NOT set `settled = true` inside -// `onClick` — keep the abort race active across the `gate.unlock()` -// await so a supersession during `AudioContext.resume()` rejects -// promptly. The unlock resolution callback bails on `settled` and -// never resolves the navigation that has already been superseded. +// Do NOT set `settled` here: the abort race must stay live across the +// `gate.unlock()` await so a supersession during `AudioContext.resume()` +// still rejects promptly. The unlock callbacks bail on `settled`. function onClickFired(state: GateState): void { if (state.settled) return; state.gate.unlock().then( @@ -147,7 +86,17 @@ function onClickFired(state: GateState): void { ); } -export function createDomAudioUnlockAdapter(host: DomAudioUnlockHost): AudioUnlockAdapter { +/** + * Build an {@link AudioUnlockAdapter} that collects a user click, calls + * `gate.unlock()`, and resolves. `mount === null` or an already-aborted + * signal rejects immediately (no button mounted); otherwise a click + * triggers `gate.unlock()` whose settlement removes the button and + * resolves/rejects. A signal abort before settlement rejects. + * Contract verified by `tests/runtime/audio-unlock-dom.test.ts`. + */ +export function createDomAudioUnlockAdapter( + host: DomAudioUnlockHost, +): AudioUnlockAdapter { return (gate: AudioUnlockContext) => new Promise((resolve, reject) => { if (host.mount === null) { diff --git a/src/runtime/audio.ts b/src/runtime/audio.ts index fcd4c46..8b63164 100644 --- a/src/runtime/audio.ts +++ b/src/runtime/audio.ts @@ -1,63 +1,26 @@ -// Audio orchestration — PUL-F024 / ADR-004. The runtime's audio service. +// Audio orchestration — PUL-F024 / ADR-004. The runtime's audio boundary, +// so scenes reach audio through `ctx.audio`, never Howler or `