@scenar/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +65 -0
  3. package/cursor/compute-position.d.ts +14 -0
  4. package/cursor/compute-position.d.ts.map +1 -0
  5. package/cursor/compute-position.js +17 -0
  6. package/cursor/compute-position.js.map +1 -0
  7. package/dom/scroll.d.ts +27 -0
  8. package/dom/scroll.d.ts.map +1 -0
  9. package/dom/scroll.js +63 -0
  10. package/dom/scroll.js.map +1 -0
  11. package/index.d.ts +15 -0
  12. package/index.d.ts.map +1 -0
  13. package/index.js +14 -0
  14. package/index.js.map +1 -0
  15. package/narration/types.d.ts +26 -0
  16. package/narration/types.d.ts.map +1 -0
  17. package/narration/types.js +11 -0
  18. package/narration/types.js.map +1 -0
  19. package/package.json +29 -0
  20. package/scenario/step-action.d.ts +105 -0
  21. package/scenario/step-action.d.ts.map +1 -0
  22. package/scenario/step-action.js +2 -0
  23. package/scenario/step-action.js.map +1 -0
  24. package/scenario/types.d.ts +35 -0
  25. package/scenario/types.d.ts.map +1 -0
  26. package/scenario/types.js +8 -0
  27. package/scenario/types.js.map +1 -0
  28. package/src/cursor/compute-position.test.ts +46 -0
  29. package/src/cursor/compute-position.ts +23 -0
  30. package/src/dom/scroll.test.ts +35 -0
  31. package/src/dom/scroll.ts +64 -0
  32. package/src/index.ts +53 -0
  33. package/src/narration/types.ts +27 -0
  34. package/src/scenario/step-action.ts +113 -0
  35. package/src/scenario/types.ts +36 -0
  36. package/src/targeting/data-attributes.test.ts +22 -0
  37. package/src/targeting/data-attributes.ts +30 -0
  38. package/src/timeline/compute-step-timeline.test.ts +60 -0
  39. package/src/timeline/compute-step-timeline.ts +49 -0
  40. package/src/timeline/derive-step.test.ts +37 -0
  41. package/src/timeline/derive-step.ts +15 -0
  42. package/src/timeline/step-duration.test.ts +38 -0
  43. package/src/timeline/step-duration.ts +25 -0
  44. package/src/timing/constants.ts +59 -0
  45. package/src/viewport/transform.ts +13 -0
  46. package/targeting/data-attributes.d.ts +21 -0
  47. package/targeting/data-attributes.d.ts.map +1 -0
  48. package/targeting/data-attributes.js +25 -0
  49. package/targeting/data-attributes.js.map +1 -0
  50. package/timeline/compute-step-timeline.d.ts +27 -0
  51. package/timeline/compute-step-timeline.d.ts.map +1 -0
  52. package/timeline/compute-step-timeline.js +26 -0
  53. package/timeline/compute-step-timeline.js.map +1 -0
  54. package/timeline/derive-step.d.ts +7 -0
  55. package/timeline/derive-step.d.ts.map +1 -0
  56. package/timeline/derive-step.js +13 -0
  57. package/timeline/derive-step.js.map +1 -0
  58. package/timeline/step-duration.d.ts +12 -0
  59. package/timeline/step-duration.d.ts.map +1 -0
  60. package/timeline/step-duration.js +18 -0
  61. package/timeline/step-duration.js.map +1 -0
  62. package/timing/constants.d.ts +55 -0
  63. package/timing/constants.d.ts.map +1 -0
  64. package/timing/constants.js +55 -0
  65. package/timing/constants.js.map +1 -0
  66. package/viewport/transform.d.ts +9 -0
  67. package/viewport/transform.d.ts.map +1 -0
  68. package/viewport/transform.js +7 -0
  69. package/viewport/transform.js.map +1 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Core scenario data types.
3
+ *
4
+ * These types define the contract between scenario authors and the
5
+ * playback engine. They are framework-agnostic — no React, no DOM.
6
+ */
7
+ import type { StepAction } from "./step-action.js";
8
+ /**
9
+ * A single step in a scenario timeline.
10
+ *
11
+ * @typeParam T - The data shape passed to the render function at this step.
12
+ */
13
+ export interface ScenarioStep<T> {
14
+ /** Milliseconds to wait before revealing this step. */
15
+ readonly delayMs: number;
16
+ /** Data snapshot at this point in the timeline. */
17
+ readonly data: T;
18
+ /** Short label shown below the demo content describing the current action. */
19
+ readonly caption?: string;
20
+ /**
21
+ * Narration script for TTS generation. Consumed by the build script
22
+ * to produce audio files — not rendered at runtime.
23
+ */
24
+ readonly narration?: string;
25
+ /**
26
+ * Timed interactions to execute while this step is active. Each
27
+ * interaction fires at a specific point during the step's duration
28
+ * (controlled by {@link StepAction.atPercent}).
29
+ *
30
+ * Interactions are executed in order. When multiple interactions
31
+ * share the same atPercent, they fire in array order.
32
+ */
33
+ readonly interactions?: readonly StepAction[];
34
+ }
35
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scenario/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACjB,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CAC/C"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Core scenario data types.
3
+ *
4
+ * These types define the contract between scenario authors and the
5
+ * playback engine. They are framework-agnostic — no React, no DOM.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/scenario/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeCursorPosition } from "./compute-position.js";
3
+
4
+ function mockContainer(rect: DOMRect, offsetWidth: number): HTMLElement {
5
+ return {
6
+ getBoundingClientRect: () => rect,
7
+ offsetWidth,
8
+ } as unknown as HTMLElement;
9
+ }
10
+
11
+ function mockElement(rect: DOMRect): Element {
12
+ return {
13
+ getBoundingClientRect: () => rect,
14
+ } as unknown as Element;
15
+ }
16
+
17
+ describe("computeCursorPosition", () => {
18
+ it("computes center position relative to container without zoom", () => {
19
+ const container = mockContainer(new DOMRect(100, 50, 800, 600), 800);
20
+ const el = mockElement(new DOMRect(300, 200, 40, 20));
21
+
22
+ const pos = computeCursorPosition(container, el);
23
+ expect(pos.x).toBe(300 - 100 + 20); // (left - containerLeft + width/2) / zoom
24
+ expect(pos.y).toBe(200 - 50 + 10);
25
+ });
26
+
27
+ it("accounts for CSS zoom", () => {
28
+ // Container renders at 2x CSS zoom: 400px offsetWidth but 800px client width
29
+ const container = mockContainer(new DOMRect(0, 0, 800, 600), 400);
30
+ const el = mockElement(new DOMRect(200, 100, 40, 20));
31
+
32
+ const pos = computeCursorPosition(container, el);
33
+ const zoom = 800 / 400; // 2x
34
+ expect(pos.x).toBe((200 + 20) / zoom);
35
+ expect(pos.y).toBe((100 + 10) / zoom);
36
+ });
37
+
38
+ it("returns origin when element is at container top-left with zero size", () => {
39
+ const container = mockContainer(new DOMRect(0, 0, 800, 600), 800);
40
+ const el = mockElement(new DOMRect(0, 0, 0, 0));
41
+
42
+ const pos = computeCursorPosition(container, el);
43
+ expect(pos.x).toBe(0);
44
+ expect(pos.y).toBe(0);
45
+ });
46
+ });
@@ -0,0 +1,23 @@
1
+ /** A 2D point in CSS coordinate space. */
2
+ export interface Position {
3
+ x: number;
4
+ y: number;
5
+ }
6
+
7
+ /**
8
+ * Compute cursor position relative to the container, accounting for
9
+ * CSS zoom on ancestors. `getBoundingClientRect` returns viewport
10
+ * coordinates (post-zoom), but `position: absolute` inside a
11
+ * CSS-zoomed container uses pre-zoom coordinates. Dividing by the
12
+ * effective zoom converts viewport offsets back to CSS space.
13
+ */
14
+ export function computeCursorPosition(container: HTMLElement, el: Element): Position {
15
+ const cRect = container.getBoundingClientRect();
16
+ const eRect = el.getBoundingClientRect();
17
+ const zoom = cRect.width / container.offsetWidth || 1;
18
+
19
+ return {
20
+ x: (eRect.left - cRect.left + eRect.width / 2) / zoom,
21
+ y: (eRect.top - cRect.top + eRect.height / 2) / zoom,
22
+ };
23
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findScrollParent } from "./scroll.js";
3
+
4
+ describe("findScrollParent", () => {
5
+ it("returns null when no scrollable ancestor exists", () => {
6
+ const el = document.createElement("div");
7
+ document.body.appendChild(el);
8
+ expect(findScrollParent(el)).toBe(null);
9
+ el.remove();
10
+ });
11
+
12
+ it("finds the nearest scrollable ancestor", () => {
13
+ const scrollable = document.createElement("div");
14
+ scrollable.style.overflowY = "auto";
15
+ const child = document.createElement("div");
16
+ scrollable.appendChild(child);
17
+ document.body.appendChild(scrollable);
18
+
19
+ expect(findScrollParent(child)).toBe(scrollable);
20
+ scrollable.remove();
21
+ });
22
+
23
+ it("skips non-scrollable intermediaries", () => {
24
+ const scrollable = document.createElement("div");
25
+ scrollable.style.overflowY = "scroll";
26
+ const middle = document.createElement("div");
27
+ const child = document.createElement("div");
28
+ scrollable.appendChild(middle);
29
+ middle.appendChild(child);
30
+ document.body.appendChild(scrollable);
31
+
32
+ expect(findScrollParent(child)).toBe(scrollable);
33
+ scrollable.remove();
34
+ });
35
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared scroll helpers used by both the Cursor component and
3
+ * the step-interactions hook.
4
+ */
5
+
6
+ /**
7
+ * Walk up the DOM tree to find the nearest scrollable ancestor
8
+ * of the given element (one with `overflow-y: auto` or `scroll`).
9
+ */
10
+ export function findScrollParent(el: Element): Element | null {
11
+ let parent = el.parentElement;
12
+ while (parent) {
13
+ const { overflowY } = getComputedStyle(parent);
14
+ if (overflowY === "auto" || overflowY === "scroll") return parent;
15
+ parent = parent.parentElement;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * Scroll the target element into view inside its nearest scrollable
22
+ * ancestor using the browser's native `scrollIntoView`. This handles
23
+ * CSS `zoom` correctly (manual `scrollTop` arithmetic does not).
24
+ *
25
+ * After scrolling the internal container, page scroll is immediately
26
+ * restored so the demo block doesn't jump on the page.
27
+ *
28
+ * @returns `true` when scrolling was necessary.
29
+ */
30
+ export function scrollTargetIntoView(el: Element): boolean {
31
+ const scrollParent = findScrollParent(el);
32
+ if (!scrollParent) return false;
33
+
34
+ const pRect = scrollParent.getBoundingClientRect();
35
+ const eRect = el.getBoundingClientRect();
36
+ const isVisible = eRect.top >= pRect.top && eRect.bottom <= pRect.bottom;
37
+ if (isVisible) return false;
38
+
39
+ const pageX = window.scrollX;
40
+ const pageY = window.scrollY;
41
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
42
+ window.scrollTo(pageX, pageY);
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Instant-scroll variant for video export where smooth scrolling
48
+ * doesn't work across Remotion frames. Sets `scrollTop` directly
49
+ * on the nearest scrollable ancestor.
50
+ */
51
+ export function scrollTargetIntoViewInstant(el: Element): void {
52
+ const scrollParent = findScrollParent(el);
53
+ if (!scrollParent) return;
54
+
55
+ const pRect = scrollParent.getBoundingClientRect();
56
+ const eRect = el.getBoundingClientRect();
57
+ const isVisible = eRect.top >= pRect.top && eRect.bottom <= pRect.bottom;
58
+ if (isVisible) return;
59
+
60
+ const pageX = window.scrollX;
61
+ const pageY = window.scrollY;
62
+ el.scrollIntoView({ block: "center", behavior: "instant" });
63
+ window.scrollTo(pageX, pageY);
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ // @scenar/core — public API
2
+ // Pure TypeScript types, timing, and utilities for scenario playback.
3
+
4
+ // Scenario types
5
+ export type { ScenarioStep } from "./scenario/types.js";
6
+ export type {
7
+ ActionType,
8
+ StepAction,
9
+ UseStepInteractionsOptions,
10
+ } from "./scenario/step-action.js";
11
+
12
+ // Narration types
13
+ export type { NarrationEntry, NarrationManifest } from "./narration/types.js";
14
+
15
+ // Viewport types
16
+ export type { ViewportTransform } from "./viewport/transform.js";
17
+ export { VIEWPORT_TRANSFORM_IDENTITY } from "./viewport/transform.js";
18
+
19
+ // Cursor types
20
+ export type { Position } from "./cursor/compute-position.js";
21
+ export { computeCursorPosition } from "./cursor/compute-position.js";
22
+
23
+ // Timeline
24
+ export type { StepTimeline } from "./timeline/compute-step-timeline.js";
25
+ export { computeStepTimeline } from "./timeline/compute-step-timeline.js";
26
+ export { deriveStepFromTime } from "./timeline/derive-step.js";
27
+ export { getStepDurationMs } from "./timeline/step-duration.js";
28
+
29
+ // Timing constants
30
+ export {
31
+ CLICK_DELAY_MS,
32
+ TYPE_CHAR_DELAY_MS,
33
+ HOVER_HOLD_MS,
34
+ DRAG_SETTLE_MS,
35
+ VIEWPORT_SETTLE_MS,
36
+ } from "./timing/constants.js";
37
+
38
+ // DOM scroll utilities
39
+ export {
40
+ findScrollParent,
41
+ scrollTargetIntoView,
42
+ scrollTargetIntoViewInstant,
43
+ } from "./dom/scroll.js";
44
+
45
+ // Data-attribute targeting contract
46
+ export {
47
+ CURSOR_TARGET_ATTRIBUTE,
48
+ SCROLL_TARGET_ATTRIBUTE,
49
+ HOVER_STATE_ATTRIBUTE,
50
+ DRAG_STATE_ATTRIBUTE,
51
+ cursorTargetSelector,
52
+ scrollTargetSelector,
53
+ } from "./targeting/data-attributes.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Runtime narration audio data produced by a TTS build script.
3
+ *
4
+ * ScenarioPlayer consumes a {@link NarrationManifest} to play audio
5
+ * clips synced to step progression. Step authors write narration text
6
+ * on scenario steps — the build script reads that text, generates
7
+ * audio files, and writes a manifest that maps step indices to audio
8
+ * URLs and durations.
9
+ */
10
+
11
+ /** A single audio clip for one scenario step. */
12
+ export interface NarrationEntry {
13
+ /** URL of the audio file (e.g. "/demos/approval-flow/step-0.mp3"). */
14
+ src: string;
15
+ /** Duration of the audio clip in milliseconds. */
16
+ durationMs: number;
17
+ }
18
+
19
+ /**
20
+ * Per-scenario manifest mapping step indices to narration audio.
21
+ *
22
+ * Array position corresponds to the step index. Steps without
23
+ * narration use `null`.
24
+ */
25
+ export interface NarrationManifest {
26
+ steps: (NarrationEntry | null)[];
27
+ }
@@ -0,0 +1,113 @@
1
+ import type { NarrationManifest } from "../narration/types.js";
2
+ import type { ViewportTransform } from "../viewport/transform.js";
3
+ import type { ScenarioStep } from "./types.js";
4
+
5
+ /**
6
+ * The set of interaction types the engine understands.
7
+ *
8
+ * Values match the proto `ActionType` enum names (`ai.scenar.scenario.v1`)
9
+ * so that proto ↔ engine mapping is a direct string copy.
10
+ */
11
+ export type ActionType =
12
+ | "scroll_to"
13
+ | "set_cursor"
14
+ | "clear_cursor"
15
+ | "click"
16
+ | "type"
17
+ | "hover"
18
+ | "drag"
19
+ | "viewport_transition";
20
+
21
+ /** A single timed interaction within a step. */
22
+ export interface StepAction {
23
+ /**
24
+ * When to fire, as a fraction of the step's narration duration
25
+ * (0.0 = step start, 1.0 = narration end). The hook reads the
26
+ * narration manifest to compute the real millisecond time.
27
+ *
28
+ * When no narration is available (muted / no manifest), the next
29
+ * step's `delayMs` is used as the fallback step duration.
30
+ */
31
+ readonly atPercent: number;
32
+ /** Action type. */
33
+ readonly type: ActionType;
34
+ /**
35
+ * Target element identifier.
36
+ * - For `scroll_to`: matches `[data-scroll-target="<target>"]`
37
+ * - For `set_cursor` / `click` / `type` / `hover`: matches `[data-cursor-target="<target>"]`
38
+ * - For `drag`: matches `[data-cursor-target="<target>"]` (drag source)
39
+ */
40
+ readonly target?: string;
41
+ /**
42
+ * Drag destination element identifier. Only used by `drag` actions.
43
+ * Matches `[data-cursor-target="<dragTarget>"]`.
44
+ */
45
+ readonly dragTarget?: string;
46
+ /** Text to type character-by-character. Only used by `type` actions. */
47
+ readonly text?: string;
48
+ /**
49
+ * Milliseconds between characters for `type` actions.
50
+ * Defaults to {@link TYPE_CHAR_DELAY_MS} (50 ms).
51
+ */
52
+ readonly typeDelay?: number;
53
+ /**
54
+ * Milliseconds to hold the cursor over the target during a `hover`
55
+ * action, between enter-event dispatch and leave-event dispatch.
56
+ * Defaults to {@link HOVER_HOLD_MS} (1500 ms).
57
+ */
58
+ readonly hoverDuration?: number;
59
+ /**
60
+ * Zoom scale factor for `viewport_transition` actions.
61
+ * Values > 1 zoom in, < 1 zoom out. Defaults to 1.5.
62
+ * Ignored when {@link viewportReset} is `true`.
63
+ */
64
+ readonly viewportZoom?: number;
65
+ /**
66
+ * When `true`, a `viewport_transition` action resets the viewport
67
+ * to the identity transform (scale 1, no translation). `target`
68
+ * and `viewportZoom` are ignored.
69
+ */
70
+ readonly viewportReset?: boolean;
71
+ }
72
+
73
+ /** Configuration for the useStepInteractions hook. */
74
+ export interface UseStepInteractionsOptions<T> {
75
+ /** Current active step index from ScenarioPlayer. */
76
+ stepIndex: number;
77
+ /** Narration manifest for duration lookup. */
78
+ narrationManifest: NarrationManifest | undefined;
79
+ /** Container ref for DOM queries. */
80
+ containerRef: { current: HTMLElement | null };
81
+ /** Callback to change the cursor target mid-step. */
82
+ setCursorTarget: (target: string | undefined) => void;
83
+ /**
84
+ * The full steps array. Interactions are read from each step's
85
+ * `interactions` field. Step `delayMs` is used as fallback duration
86
+ * when narration is unavailable.
87
+ */
88
+ steps: readonly ScenarioStep<T>[];
89
+ /**
90
+ * Playback speed multiplier (default 1). Browser-path timeouts are
91
+ * divided by this value so interactions fire proportionally earlier
92
+ * at higher speeds.
93
+ */
94
+ playbackRate?: number;
95
+ /**
96
+ * Optional callback to control the Cursor's click ripple. The
97
+ * `hover` action calls `setShowRipple(false)` before moving the
98
+ * cursor and `setShowRipple(true)` after hover leave events fire.
99
+ */
100
+ setShowRipple?: (show: boolean) => void;
101
+ /**
102
+ * Optional callback to control the Cursor's drag visual. The
103
+ * `drag` action calls `setDragging(true)` after pressing at the
104
+ * source and `setDragging(false)` after releasing at the destination.
105
+ */
106
+ setDragging?: (dragging: boolean) => void;
107
+ /**
108
+ * Optional callback to apply a viewport transform (zoom/pan). The
109
+ * `viewport_transition` action computes scale and translate values
110
+ * needed to center the target element and calls this callback.
111
+ */
112
+ setViewportTransform?: (transform: ViewportTransform) => void;
113
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Core scenario data types.
3
+ *
4
+ * These types define the contract between scenario authors and the
5
+ * playback engine. They are framework-agnostic — no React, no DOM.
6
+ */
7
+
8
+ import type { StepAction } from "./step-action.js";
9
+
10
+ /**
11
+ * A single step in a scenario timeline.
12
+ *
13
+ * @typeParam T - The data shape passed to the render function at this step.
14
+ */
15
+ export interface ScenarioStep<T> {
16
+ /** Milliseconds to wait before revealing this step. */
17
+ readonly delayMs: number;
18
+ /** Data snapshot at this point in the timeline. */
19
+ readonly data: T;
20
+ /** Short label shown below the demo content describing the current action. */
21
+ readonly caption?: string;
22
+ /**
23
+ * Narration script for TTS generation. Consumed by the build script
24
+ * to produce audio files — not rendered at runtime.
25
+ */
26
+ readonly narration?: string;
27
+ /**
28
+ * Timed interactions to execute while this step is active. Each
29
+ * interaction fires at a specific point during the step's duration
30
+ * (controlled by {@link StepAction.atPercent}).
31
+ *
32
+ * Interactions are executed in order. When multiple interactions
33
+ * share the same atPercent, they fire in array order.
34
+ */
35
+ readonly interactions?: readonly StepAction[];
36
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ CURSOR_TARGET_ATTRIBUTE,
4
+ SCROLL_TARGET_ATTRIBUTE,
5
+ cursorTargetSelector,
6
+ scrollTargetSelector,
7
+ } from "./data-attributes.js";
8
+
9
+ describe("data-attributes", () => {
10
+ it("exports correct attribute names", () => {
11
+ expect(CURSOR_TARGET_ATTRIBUTE).toBe("data-cursor-target");
12
+ expect(SCROLL_TARGET_ATTRIBUTE).toBe("data-scroll-target");
13
+ });
14
+
15
+ it("builds correct cursor target selectors", () => {
16
+ expect(cursorTargetSelector("submit-btn")).toBe('[data-cursor-target="submit-btn"]');
17
+ });
18
+
19
+ it("builds correct scroll target selectors", () => {
20
+ expect(scrollTargetSelector("results")).toBe('[data-scroll-target="results"]');
21
+ });
22
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Centralized data-attribute contract for the scenario engine.
3
+ *
4
+ * The engine identifies interactive elements in the DOM via these
5
+ * data attributes. This module is the single source of truth for
6
+ * attribute names and selector builders — consumers never hard-code
7
+ * attribute strings.
8
+ */
9
+
10
+ /** Attribute name for cursor-targetable elements. */
11
+ export const CURSOR_TARGET_ATTRIBUTE = "data-cursor-target";
12
+
13
+ /** Attribute name for scroll-targetable elements. */
14
+ export const SCROLL_TARGET_ATTRIBUTE = "data-scroll-target";
15
+
16
+ /** Attribute set on elements during hover interactions. */
17
+ export const HOVER_STATE_ATTRIBUTE = "data-hover";
18
+
19
+ /** Attribute set on elements during drag interactions. */
20
+ export const DRAG_STATE_ATTRIBUTE = "data-dragging";
21
+
22
+ /** Build a CSS selector for a cursor target by its ID. */
23
+ export function cursorTargetSelector(id: string): string {
24
+ return `[${CURSOR_TARGET_ATTRIBUTE}="${id}"]`;
25
+ }
26
+
27
+ /** Build a CSS selector for a scroll target by its ID. */
28
+ export function scrollTargetSelector(id: string): string {
29
+ return `[${SCROLL_TARGET_ATTRIBUTE}="${id}"]`;
30
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeStepTimeline } from "./compute-step-timeline.js";
3
+
4
+ describe("computeStepTimeline", () => {
5
+ it("returns zero duration for a single step with no narration", () => {
6
+ const tl = computeStepTimeline([{ delayMs: 0 }], null);
7
+ expect(tl.stepStartTimesMs).toEqual([0]);
8
+ expect(tl.totalDurationMs).toBe(3000);
9
+ });
10
+
11
+ it("uses delayMs when no narration manifest is provided", () => {
12
+ const tl = computeStepTimeline(
13
+ [{ delayMs: 0 }, { delayMs: 1000 }, { delayMs: 2000 }],
14
+ null,
15
+ );
16
+ expect(tl.stepStartTimesMs).toEqual([0, 1000, 3000]);
17
+ expect(tl.totalDurationMs).toBe(3000 + 3000);
18
+ });
19
+
20
+ it("uses narration duration when it exceeds delayMs", () => {
21
+ const tl = computeStepTimeline(
22
+ [{ delayMs: 0 }, { delayMs: 500 }, { delayMs: 500 }],
23
+ { steps: [{ src: "a.mp3", durationMs: 2000 }, { src: "b.mp3", durationMs: 1000 }, null] },
24
+ );
25
+ // Step 1 starts after max(500, 2000) = 2000
26
+ expect(tl.stepStartTimesMs[1]).toBe(2000);
27
+ // Step 2 starts after 2000 + max(500, 1000) = 3000
28
+ expect(tl.stepStartTimesMs[2]).toBe(3000);
29
+ });
30
+
31
+ it("uses delayMs when it exceeds narration duration", () => {
32
+ const tl = computeStepTimeline(
33
+ [{ delayMs: 0 }, { delayMs: 5000 }],
34
+ { steps: [{ src: "a.mp3", durationMs: 1000 }, null] },
35
+ );
36
+ expect(tl.stepStartTimesMs[1]).toBe(5000);
37
+ });
38
+
39
+ it("handles null entries in the manifest", () => {
40
+ const tl = computeStepTimeline(
41
+ [{ delayMs: 0 }, { delayMs: 1000 }],
42
+ { steps: [null, null] },
43
+ );
44
+ expect(tl.stepStartTimesMs).toEqual([0, 1000]);
45
+ });
46
+
47
+ it("uses last step narration for total duration when it exceeds dwell", () => {
48
+ const tl = computeStepTimeline(
49
+ [{ delayMs: 0 }, { delayMs: 1000 }],
50
+ { steps: [null, { src: "b.mp3", durationMs: 5000 }] },
51
+ );
52
+ expect(tl.totalDurationMs).toBe(1000 + 5000);
53
+ });
54
+
55
+ it("handles empty steps array", () => {
56
+ const tl = computeStepTimeline([], null);
57
+ expect(tl.stepStartTimesMs).toEqual([0]);
58
+ expect(tl.totalDurationMs).toBe(3000);
59
+ });
60
+ });
@@ -0,0 +1,49 @@
1
+ import type { NarrationManifest } from "../narration/types.js";
2
+
3
+ /**
4
+ * Minimal step shape for timeline computation. Accepts any
5
+ * ScenarioStep<T> without caring about the data payload.
6
+ */
7
+ interface StepTiming {
8
+ delayMs: number;
9
+ }
10
+
11
+ /** Dwell time on the final step so viewers can absorb the result. */
12
+ const FINAL_DWELL_MS = 3_000;
13
+
14
+ export interface StepTimeline {
15
+ /** Start time of each step in milliseconds (index 0 is always 0). */
16
+ stepStartTimesMs: number[];
17
+ /** Total playback duration in milliseconds. */
18
+ totalDurationMs: number;
19
+ }
20
+
21
+ /**
22
+ * Pre-compute step start times and total duration from step definitions
23
+ * and an optional narration manifest.
24
+ *
25
+ * Step N+1 starts after `max(steps[N+1].delayMs, manifest.steps[N].durationMs)` —
26
+ * whichever is longer, the base delay or the narration clip for the current step.
27
+ *
28
+ * Shared between browser ScenarioPlayer (progress bar) and Remotion
29
+ * video export (frame-based timeline).
30
+ */
31
+ export function computeStepTimeline(
32
+ steps: readonly StepTiming[],
33
+ manifest: NarrationManifest | null | undefined,
34
+ ): StepTimeline {
35
+ const stepStartTimesMs: number[] = [0];
36
+
37
+ for (let i = 1; i < steps.length; i++) {
38
+ const prevStart = stepStartTimesMs[i - 1]!;
39
+ const baseDelay = steps[i]!.delayMs;
40
+ const narrationMs = manifest?.steps[i - 1]?.durationMs ?? 0;
41
+ stepStartTimesMs.push(prevStart + Math.max(baseDelay, narrationMs));
42
+ }
43
+
44
+ const lastStepStart = stepStartTimesMs[stepStartTimesMs.length - 1] ?? 0;
45
+ const lastNarrationMs = manifest?.steps[steps.length - 1]?.durationMs ?? 0;
46
+ const totalDurationMs = lastStepStart + Math.max(FINAL_DWELL_MS, lastNarrationMs);
47
+
48
+ return { stepStartTimesMs, totalDurationMs };
49
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveStepFromTime } from "./derive-step.js";
3
+
4
+ describe("deriveStepFromTime", () => {
5
+ const starts = [0, 1000, 3000, 6000];
6
+ const maxIndex = 3;
7
+
8
+ it("returns 0 for time before all steps", () => {
9
+ expect(deriveStepFromTime(-1, starts, maxIndex)).toBe(0);
10
+ });
11
+
12
+ it("returns 0 at time 0", () => {
13
+ expect(deriveStepFromTime(0, starts, maxIndex)).toBe(0);
14
+ });
15
+
16
+ it("returns the correct step for times within each range", () => {
17
+ expect(deriveStepFromTime(500, starts, maxIndex)).toBe(0);
18
+ expect(deriveStepFromTime(1000, starts, maxIndex)).toBe(1);
19
+ expect(deriveStepFromTime(2500, starts, maxIndex)).toBe(1);
20
+ expect(deriveStepFromTime(3000, starts, maxIndex)).toBe(2);
21
+ expect(deriveStepFromTime(6000, starts, maxIndex)).toBe(3);
22
+ expect(deriveStepFromTime(99999, starts, maxIndex)).toBe(3);
23
+ });
24
+
25
+ it("clamps result to maxIndex", () => {
26
+ expect(deriveStepFromTime(6000, starts, 1)).toBe(1);
27
+ });
28
+
29
+ it("returns 0 for empty start times", () => {
30
+ expect(deriveStepFromTime(5000, [], 0)).toBe(0);
31
+ });
32
+
33
+ it("handles single step", () => {
34
+ expect(deriveStepFromTime(0, [0], 0)).toBe(0);
35
+ expect(deriveStepFromTime(5000, [0], 0)).toBe(0);
36
+ });
37
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Find the active step for a given playback time by scanning the
3
+ * pre-computed step start times in reverse. Returns `0` if the
4
+ * time precedes all steps.
5
+ */
6
+ export function deriveStepFromTime(
7
+ currentTimeMs: number,
8
+ stepStartTimesMs: readonly number[],
9
+ maxIndex: number,
10
+ ): number {
11
+ for (let i = stepStartTimesMs.length - 1; i >= 0; i--) {
12
+ if (currentTimeMs >= stepStartTimesMs[i]!) return Math.min(i, maxIndex);
13
+ }
14
+ return 0;
15
+ }