@bendyline/squisq-react 1.3.2 → 1.4.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 (46) hide show
  1. package/README.md +57 -23
  2. package/dist/index.d.ts +131 -26
  3. package/dist/index.js +1475 -755
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +49 -13
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +2263 -0
  11. package/package.json +9 -5
  12. package/src/BlockRenderer.tsx +15 -7
  13. package/src/DocPlayer.tsx +222 -55
  14. package/src/DocPlayerWithSidebar.tsx +21 -9
  15. package/src/DocProgressBar.tsx +21 -3
  16. package/src/LinearDocView.tsx +69 -206
  17. package/src/MarkdownRenderer.tsx +182 -41
  18. package/src/MediaClipLayer.tsx +135 -0
  19. package/src/__tests__/DocPlayer.test.tsx +81 -0
  20. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
  21. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  22. package/src/__tests__/LinearDocView.test.tsx +53 -1
  23. package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
  24. package/src/__tests__/PathLayer.test.tsx +73 -0
  25. package/src/__tests__/fillStyle.test.tsx +112 -0
  26. package/src/__tests__/transitionStyles.test.ts +125 -0
  27. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  28. package/src/__tests__/useJsonViewTokens.test.ts +41 -0
  29. package/src/__tests__/useSlideSwipe.test.ts +81 -0
  30. package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
  31. package/src/hooks/index.ts +7 -2
  32. package/src/hooks/useAudioSync.ts +19 -5
  33. package/src/hooks/useDocPlayback.ts +81 -100
  34. package/src/hooks/useMediaSchedule.ts +39 -0
  35. package/src/hooks/useSlideSwipe.ts +265 -0
  36. package/src/index.ts +8 -1
  37. package/src/jsonView/useJsonViewTokens.ts +6 -31
  38. package/src/layers/ImageLayer.tsx +11 -1
  39. package/src/layers/PathLayer.tsx +146 -0
  40. package/src/layers/ShapeLayer.tsx +27 -5
  41. package/src/layers/TextLayer.tsx +395 -22
  42. package/src/layers/VideoLayer.tsx +16 -9
  43. package/src/layers/index.ts +1 -0
  44. package/src/standalone-entry.tsx +1 -1
  45. package/src/styles/doc-animations.css +1936 -35
  46. package/src/utils/fillStyle.tsx +148 -0
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { TransitionDirection, TransitionType } from '@bendyline/squisq/schemas';
5
+ import { TRANSITION_TYPES } from '@bendyline/squisq/schemas';
6
+ import { getTransitionClass } from '../utils/animationUtils';
7
+
8
+ const transitionCss = readFileSync(
9
+ resolve(process.cwd(), 'packages/react/src/styles/doc-animations.css'),
10
+ 'utf8',
11
+ );
12
+
13
+ const directionalTypes: TransitionType[] = [
14
+ 'cover',
15
+ 'pan',
16
+ 'pull',
17
+ 'push',
18
+ 'reveal',
19
+ 'strips',
20
+ 'uncover',
21
+ 'wipe',
22
+ ];
23
+
24
+ const axisTypes: TransitionType[] = ['blinds', 'comb', 'randomBar', 'randomBars', 'split'];
25
+ const cardinalDirections = [
26
+ 'left',
27
+ 'right',
28
+ 'up',
29
+ 'down',
30
+ ] as const satisfies readonly TransitionDirection[];
31
+ const axisDirections = ['horizontal', 'vertical'] as const satisfies readonly TransitionDirection[];
32
+
33
+ function collectGeneratedTransitionClasses(): string[] {
34
+ const classNames = new Set<string>();
35
+
36
+ for (const type of TRANSITION_TYPES) {
37
+ if (type === 'cut') continue;
38
+ classNames.add(getTransitionClass(type, true));
39
+ classNames.add(getTransitionClass(type, false));
40
+ }
41
+
42
+ for (const type of directionalTypes) {
43
+ for (const direction of cardinalDirections) {
44
+ classNames.add(getTransitionClass(type, true, direction));
45
+ classNames.add(getTransitionClass(type, false, direction));
46
+ }
47
+ }
48
+
49
+ for (const type of axisTypes) {
50
+ for (const direction of axisDirections) {
51
+ classNames.add(getTransitionClass(type, true, direction));
52
+ classNames.add(getTransitionClass(type, false, direction));
53
+ }
54
+ }
55
+
56
+ return [...classNames].sort();
57
+ }
58
+
59
+ describe('transition CSS coverage', () => {
60
+ it('contains enter and exit selectors for every generated transition class', () => {
61
+ for (const className of collectGeneratedTransitionClasses()) {
62
+ expect(transitionCss, className).toContain(`.${className}`);
63
+ }
64
+ });
65
+ });
66
+
67
+ /** All `@keyframes <name>` defined in the stylesheet. */
68
+ function definedKeyframes(): Set<string> {
69
+ const names = new Set<string>();
70
+ for (const m of transitionCss.matchAll(/@keyframes\s+([A-Za-z0-9_-]+)/g)) {
71
+ names.add(m[1]);
72
+ }
73
+ return names;
74
+ }
75
+
76
+ /**
77
+ * Map each `.transition-<x>-(enter|exit)` selector to the keyframe its rule
78
+ * animates. Parses top-level `selectors { body }` blocks and reads the first
79
+ * identifier of the `animation:` shorthand. Returns null for a transition rule
80
+ * with no `animation` property (itself a defect).
81
+ */
82
+ function transitionRuleKeyframes(): Map<string, string | null> {
83
+ const map = new Map<string, string | null>();
84
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
85
+ let rule: RegExpExecArray | null;
86
+ while ((rule = ruleRe.exec(transitionCss)) !== null) {
87
+ const selectors = rule[1].split(',').map((s) => s.trim());
88
+ const transitionSelectors = selectors.filter((s) =>
89
+ /\.transition-[A-Za-z0-9-]+-(?:enter|exit)\b/.test(s),
90
+ );
91
+ if (transitionSelectors.length === 0) continue;
92
+ const animMatch = rule[2].match(/animation:\s*([A-Za-z0-9_-]+)/);
93
+ const keyframe = animMatch ? animMatch[1] : null;
94
+ for (const selector of transitionSelectors) {
95
+ const classMatch = selector.match(/\.(transition-[A-Za-z0-9-]+-(?:enter|exit))/);
96
+ if (classMatch && !map.has(classMatch[1])) map.set(classMatch[1], keyframe);
97
+ }
98
+ }
99
+ return map;
100
+ }
101
+
102
+ describe('transition keyframe integrity', () => {
103
+ it('every transition rule animates a defined @keyframes (catches typos)', () => {
104
+ const keyframes = definedKeyframes();
105
+ const broken: string[] = [];
106
+ for (const [selector, keyframe] of transitionRuleKeyframes()) {
107
+ if (keyframe === null) broken.push(`${selector}: no animation property`);
108
+ else if (!keyframes.has(keyframe)) broken.push(`${selector}: missing @keyframes ${keyframe}`);
109
+ }
110
+ expect(broken, broken.join('\n')).toEqual([]);
111
+ });
112
+
113
+ it('every generated transition class resolves to a defined keyframe', () => {
114
+ // Ties the function output to the CSS end-to-end: a class the player can
115
+ // emit must have a rule, and that rule must animate a real keyframe.
116
+ const keyframes = definedKeyframes();
117
+ const rules = transitionRuleKeyframes();
118
+ for (const className of collectGeneratedTransitionClasses()) {
119
+ const keyframe = rules.get(className);
120
+ expect(keyframe, `${className} has no transition rule`).toBeDefined();
121
+ expect(keyframe, `${className} animates undefined keyframe ${keyframe}`).not.toBeNull();
122
+ if (keyframe) expect(keyframes.has(keyframe), `${className} -> ${keyframe}`).toBe(true);
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * useDocPlayback — block transition state is derived synchronously.
3
+ *
4
+ * Regression guard for the between-block "flash": a newly active block must
5
+ * report `isEntering` (and its outgoing `previousBlock`) on the SAME render it
6
+ * becomes current — not a frame later via an effect. Otherwise the block paints
7
+ * fully settled for a frame and then snaps back to the start of its entrance
8
+ * animation. The distinguishing assertion is `previousBlock` during the next
9
+ * block's entrance with NO timer/effect advanced (the old effect-based path
10
+ * only set it after a `setTimeout`).
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import { renderHook } from '@testing-library/react';
15
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
16
+ import type { Doc, Block } from '@bendyline/squisq/schemas';
17
+ import { useDocPlayback } from '../hooks/useDocPlayback';
18
+
19
+ function block(id: string, startTime: number): Block {
20
+ return {
21
+ id,
22
+ startTime,
23
+ duration: 5,
24
+ audioSegment: 0,
25
+ transition: { type: 'fade', duration: 0.5 },
26
+ layers: [],
27
+ };
28
+ }
29
+
30
+ const doc: Doc = {
31
+ articleId: 'd',
32
+ duration: 10,
33
+ blocks: [block('a', 0), block('b', 5)],
34
+ audio: { segments: [] },
35
+ };
36
+
37
+ describe('useDocPlayback — synchronous block transitions', () => {
38
+ it('exposes the entering block + outgoing previousBlock on the same render (no effect flush)', () => {
39
+ const { result, rerender } = renderHook(
40
+ ({ t }: { t: number }) => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape),
41
+ { initialProps: { t: 0 } },
42
+ );
43
+
44
+ // Establish block A as the current block first.
45
+ expect(result.current.currentBlockIndex).toBe(0);
46
+
47
+ // Cross into block B's entrance window: it is entering AND crossfading from
48
+ // A immediately — the old code left previousBlock null until a setTimeout.
49
+ rerender({ t: 5.1 });
50
+ expect(result.current.currentBlockIndex).toBe(1);
51
+ expect(result.current.isEntering).toBe(true);
52
+ expect(result.current.isExiting).toBe(true);
53
+ expect(result.current.previousBlock?.id).toBe('a');
54
+
55
+ // Past the entrance window: settled, no crossfade.
56
+ rerender({ t: 7 });
57
+ expect(result.current.isEntering).toBe(false);
58
+ expect(result.current.isExiting).toBe(false);
59
+ expect(result.current.previousBlock).toBeNull();
60
+ });
61
+
62
+ it('isEntering is a pure function of blockTime vs the transition duration', () => {
63
+ const enteringAt = (t: number) =>
64
+ renderHook(() => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape)).result.current
65
+ .isEntering;
66
+ expect(enteringAt(5.0)).toBe(true); // blockTime 0.0 < 0.5
67
+ expect(enteringAt(5.4)).toBe(true); // blockTime 0.4 < 0.5
68
+ expect(enteringAt(5.6)).toBe(false); // blockTime 0.6 >= 0.5
69
+ });
70
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME } from '@bendyline/squisq/schemas';
4
+ import { useJsonViewTokens } from '../jsonView/useJsonViewTokens';
5
+
6
+ type StyleBag = Record<string, string>;
7
+
8
+ function mockPrefersDark(dark: boolean): void {
9
+ Object.defineProperty(window, 'matchMedia', {
10
+ configurable: true,
11
+ value: (query: string) => ({
12
+ matches: dark && query.includes('dark'),
13
+ media: query,
14
+ onchange: null,
15
+ addListener: vi.fn(),
16
+ removeListener: vi.fn(),
17
+ addEventListener: vi.fn(),
18
+ removeEventListener: vi.fn(),
19
+ dispatchEvent: vi.fn(() => false),
20
+ }),
21
+ });
22
+ }
23
+
24
+ describe('useJsonViewTokens', () => {
25
+ afterEach(() => vi.restoreAllMocks());
26
+
27
+ it('emits json-prefixed tokens for a static surface', () => {
28
+ const { result } = renderHook(() => useJsonViewTokens(DEFAULT_THEME, LIGHT_SURFACE));
29
+ const style = result.current.style as StyleBag;
30
+ expect(style['--squisq-json-bg']).toBe(LIGHT_SURFACE.background);
31
+ expect(style['--squisq-json-text']).toBe(LIGHT_SURFACE.text);
32
+ });
33
+
34
+ it("tracks a dark OS preference under surface='auto'", () => {
35
+ mockPrefersDark(true);
36
+ const { result } = renderHook(() => useJsonViewTokens(DEFAULT_THEME, 'auto'));
37
+ const style = result.current.style as StyleBag;
38
+ expect(style['--squisq-json-bg']).toBe(DARK_SURFACE.background);
39
+ expect(result.current.theme.colors.background).toBe(DARK_SURFACE.background);
40
+ });
41
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { decideSwipe, type SwipeDecisionInput } from '../hooks/useSlideSwipe';
3
+
4
+ /** A slow drag: long duration so velocity stays well under the flick threshold. */
5
+ function slow(overrides: Partial<SwipeDecisionInput>): SwipeDecisionInput {
6
+ return {
7
+ dx: 0,
8
+ width: 1000,
9
+ elapsedMs: 1000,
10
+ canNext: true,
11
+ canPrev: true,
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe('decideSwipe', () => {
17
+ describe('distance threshold (30% of width)', () => {
18
+ it('snaps back when the drag is below the threshold', () => {
19
+ // 100px of 1000px = 10% < 30%, and slow enough not to be a flick.
20
+ expect(decideSwipe(slow({ dx: -100 }))).toBe('snap');
21
+ expect(decideSwipe(slow({ dx: 100 }))).toBe('snap');
22
+ });
23
+
24
+ it('advances to the next slide when dragged left past the threshold', () => {
25
+ expect(decideSwipe(slow({ dx: -350 }))).toBe('next');
26
+ });
27
+
28
+ it('goes to the previous slide when dragged right past the threshold', () => {
29
+ expect(decideSwipe(slow({ dx: 350 }))).toBe('prev');
30
+ });
31
+
32
+ it('commits exactly at the threshold', () => {
33
+ expect(decideSwipe(slow({ dx: -300 }))).toBe('next');
34
+ });
35
+ });
36
+
37
+ describe('flick (fast, short drag)', () => {
38
+ it('commits on a quick flick even below the distance threshold', () => {
39
+ // 100px in 100ms = 1.0 px/ms >= 0.5, distance 100 >= 12.
40
+ expect(decideSwipe(slow({ dx: -100, elapsedMs: 100 }))).toBe('next');
41
+ expect(decideSwipe(slow({ dx: 100, elapsedMs: 100 }))).toBe('prev');
42
+ });
43
+
44
+ it('ignores a fast micro-jitter shorter than the minimum flick distance', () => {
45
+ // 8px in 5ms = 1.6 px/ms (fast) but distance 8 < 12 → treat as a click.
46
+ expect(decideSwipe(slow({ dx: -8, elapsedMs: 5 }))).toBe('snap');
47
+ });
48
+ });
49
+
50
+ describe('deck boundaries', () => {
51
+ it('snaps back instead of advancing when there is no next slide', () => {
52
+ expect(decideSwipe(slow({ dx: -350, canNext: false }))).toBe('snap');
53
+ // A flick past the last slide is also refused.
54
+ expect(decideSwipe(slow({ dx: -100, elapsedMs: 100, canNext: false }))).toBe('snap');
55
+ });
56
+
57
+ it('snaps back instead of rewinding when there is no previous slide', () => {
58
+ expect(decideSwipe(slow({ dx: 350, canPrev: false }))).toBe('snap');
59
+ expect(decideSwipe(slow({ dx: 100, elapsedMs: 100, canPrev: false }))).toBe('snap');
60
+ });
61
+ });
62
+
63
+ describe('degenerate inputs', () => {
64
+ it('snaps back on a zero-distance release', () => {
65
+ expect(decideSwipe(slow({ dx: 0 }))).toBe('snap');
66
+ });
67
+
68
+ it('never commits by distance when the container has no measurable width', () => {
69
+ // width 0 → distance threshold is Infinity; only a flick can commit.
70
+ // 500px over 2000ms = 0.25 px/ms, safely below the flick velocity.
71
+ expect(decideSwipe(slow({ dx: -500, width: 0, elapsedMs: 2000 }))).toBe('snap');
72
+ expect(decideSwipe(slow({ dx: -500, width: 0, elapsedMs: 100 }))).toBe('next');
73
+ });
74
+
75
+ it('does not divide by zero on an instantaneous release', () => {
76
+ // elapsedMs 0 → velocity treated as 0; falls back to the distance rule.
77
+ expect(decideSwipe(slow({ dx: -350, elapsedMs: 0 }))).toBe('next');
78
+ expect(decideSwipe(slow({ dx: -100, elapsedMs: 0 }))).toBe('snap');
79
+ });
80
+ });
81
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * AudioProvider - Abstraction for audio playback in DocPlayer
2
+ * AudioController - Abstraction for audio playback in DocPlayer
3
3
  *
4
4
  * This module defines an interface for audio playback operations that can have
5
5
  * different implementations depending on the runtime environment:
@@ -47,9 +47,9 @@ export interface AudioActions {
47
47
  restart: () => Promise<void>;
48
48
  }
49
49
 
50
- export type AudioProvider = AudioState & AudioActions;
50
+ export type AudioController = AudioState & AudioActions;
51
51
 
52
- export interface AudioProviderConfig {
52
+ export interface AudioControllerConfig {
53
53
  /** Audio track with segments */
54
54
  audioTrack: AudioTrack | undefined;
55
55
  /** Base path for resolving audio URLs */
@@ -1,5 +1,10 @@
1
- export { calculateSegmentTiming, findSegmentAtTime } from './AudioProvider';
2
- export type { AudioState, AudioActions, AudioProvider, AudioProviderConfig } from './AudioProvider';
1
+ export { calculateSegmentTiming, findSegmentAtTime } from './AudioController';
2
+ export type {
3
+ AudioState,
4
+ AudioActions,
5
+ AudioController,
6
+ AudioControllerConfig,
7
+ } from './AudioController';
3
8
 
4
9
  export { useAudioSync } from './useAudioSync';
5
10
  export { useDocPlayback } from './useDocPlayback';
@@ -7,20 +7,21 @@
7
7
  * Handles multiple audio segments (MP3 files) by tracking which segment
8
8
  * is currently playing and calculating the overall timeline position.
9
9
  *
10
- * This is the HTML5 Audio implementation of the AudioProvider interface.
11
- * For EFB/MSFS environments, use useCompanionAudioSync instead.
10
+ * This is the HTML5 Audio implementation of the AudioController interface.
11
+ * Hosts that drive audio through an external player (e.g. a native shell)
12
+ * can supply their own AudioController to DocPlayer instead of this hook.
12
13
  */
13
14
 
14
15
  import { useState, useEffect, useRef, useCallback } from 'react';
15
16
  import type { RefObject } from 'react';
16
17
  import type { AudioTrack } from '@bendyline/squisq/schemas';
17
- import type { AudioProvider } from './AudioProvider';
18
+ import type { AudioController } from './AudioController';
18
19
 
19
20
  export function useAudioSync(
20
21
  audioRef: RefObject<HTMLAudioElement>,
21
22
  audioTrack: AudioTrack | undefined,
22
23
  basePath: string = '',
23
- ): AudioProvider {
24
+ ): AudioController {
24
25
  const [currentTime, setCurrentTime] = useState(0);
25
26
  const [isPlaying, setIsPlaying] = useState(false);
26
27
  const [currentSegment, setCurrentSegment] = useState(0);
@@ -120,6 +121,13 @@ export function useAudioSync(
120
121
  if (!audio) return;
121
122
 
122
123
  const handleTimeUpdate = () => {
124
+ // In fallback mode the <audio> element is NOT the clock — there's no real
125
+ // source (e.g. the editor preview's synthetic, empty-src track), so the
126
+ // synthetic timer and `seekTo` own `currentTime`. A `timeupdate` fired as
127
+ // a side effect of programmatically setting `audio.currentTime` (during a
128
+ // seek) would otherwise clobber the just-seeked position with the empty
129
+ // element's unreliable `currentTime`, snapping the scrubber back.
130
+ if (fallbackMode.current) return;
123
131
  // Calculate overall timeline position
124
132
  const segmentStart = segmentStarts.current[currentSegment] || 0;
125
133
  const overallTime = segmentStart + audio.currentTime;
@@ -127,7 +135,13 @@ export function useAudioSync(
127
135
  };
128
136
 
129
137
  const handlePlay = () => {
130
- fallbackMode.current = false;
138
+ // Don't clear `fallbackMode` here. Whether the <audio> element is really
139
+ // the clock is decided authoritatively by the play() promise: it only
140
+ // resolves (clearing fallback, see `play`) when a real source actually
141
+ // plays. The 'play' event, by contrast, can fire spuriously on the
142
+ // source-less preview element — and clearing fallback there makes the
143
+ // synthetic timer's tick guard bail on its next frame, freezing the
144
+ // clock and the scrubber after a seek/resume.
131
145
  setIsPlaying(true);
132
146
  };
133
147
  const handlePause = () => setIsPlaying(false);
@@ -12,14 +12,20 @@
12
12
  * - Automatic expansion of template blocks
13
13
  */
14
14
 
15
- import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
15
+ import { useMemo, useCallback, useRef } from 'react';
16
16
  import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
17
17
  import type { Theme } from '@bendyline/squisq/schemas';
18
- import { getBlockAtTime } from '@bendyline/squisq/schemas';
18
+ import {
19
+ DEFAULT_THEME,
20
+ getBlockAtTime,
21
+ resolveBlockTransition,
22
+ resolveTransitionDuration,
23
+ } from '@bendyline/squisq/schemas';
19
24
  import {
20
25
  expandDocBlocks,
21
- flattenBlocks,
26
+ flattenRenderableBlocks,
22
27
  isTemplateBlock,
28
+ resolvePersistentLayers,
23
29
  VIEWPORT_PRESETS,
24
30
  type ViewportConfig,
25
31
  } from '@bendyline/squisq/doc';
@@ -61,29 +67,34 @@ export function useDocPlayback(
61
67
  renderMode: boolean = false,
62
68
  theme?: Theme,
63
69
  ): PlaybackState & PlaybackActions {
64
- const [transitionState, setTransitionState] = useState<{
65
- entering: boolean;
66
- exiting: boolean;
67
- previousBlock: Block | null;
68
- }>({
69
- entering: false,
70
- exiting: false,
71
- previousBlock: null,
72
- });
73
-
70
+ // `renderMode` is retained for API/signature compatibility; block transitions
71
+ // are now computed identically for real-time and render (export) modes.
72
+ void renderMode;
74
73
  // Expand any template blocks into full blocks
75
74
  const blocks = useMemo(() => {
76
75
  if (!script?.blocks) {
77
76
  return [];
78
77
  }
79
78
 
80
- // Flatten nested block hierarchy (markdown-derived docs have children)
79
+ // Flatten nested block hierarchy (markdown-derived docs have children).
80
+ // `flattenRenderableBlocks` skips the children of container templates
81
+ // (`diagram`, `drawing`) — those are consumed by the parent's render as
82
+ // nodes/shapes, so they must not also appear as their own slides.
81
83
  const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
82
- const flatBlocks = hasChildren ? flattenBlocks(script.blocks) : script.blocks;
84
+ const flatBlocks = hasChildren ? flattenRenderableBlocks(script.blocks) : script.blocks;
83
85
 
84
86
  // Check if any blocks are templates
85
87
  const hasTemplates = flatBlocks.some(isTemplateBlock);
86
88
 
89
+ // Doc persistent layers win wholesale; docs without any inherit the
90
+ // theme's (see resolvePersistentLayers). Passed as a narrow object so
91
+ // the memo deps stay field-precise.
92
+ const resolvedTheme = theme ?? DEFAULT_THEME;
93
+ const persistentLayers = resolvePersistentLayers(
94
+ { persistentLayers: script.persistentLayers },
95
+ resolvedTheme,
96
+ );
97
+
87
98
  if (hasTemplates) {
88
99
  // Extract audio segment timing for proper block synchronization
89
100
  const audioSegments = script.audio?.segments?.map((seg) => ({
@@ -95,15 +106,31 @@ export function useDocPlayback(
95
106
  const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
96
107
  audioSegments,
97
108
  viewport,
98
- persistentLayers: script.persistentLayers,
109
+ persistentLayers,
99
110
  theme,
111
+ // Custom (user-defined) templates inlined into the doc's
112
+ // frontmatter — see CustomTemplates.ts. Merged onto the
113
+ // built-in registry so blocks annotated with `{[myhero]}`
114
+ // resolve through the user's design.
115
+ customTemplates: script.customTemplates,
100
116
  });
101
117
  return expanded;
102
118
  }
103
119
 
104
- // All raw blocks, use as-is
105
- return flatBlocks;
106
- }, [script?.blocks, script?.audio?.segments, script?.persistentLayers, viewport, theme]);
120
+ // All raw blocks used as-is except for the theme's default transition
121
+ // fallback (copies, never mutations: these blocks are caller-owned).
122
+ return flatBlocks.map((block, index) => {
123
+ const transition = resolveBlockTransition(block, resolvedTheme, index);
124
+ return transition !== block.transition ? { ...block, transition } : block;
125
+ });
126
+ }, [
127
+ script?.blocks,
128
+ script?.audio?.segments,
129
+ script?.persistentLayers,
130
+ script?.customTemplates,
131
+ viewport,
132
+ theme,
133
+ ]);
107
134
 
108
135
  // Find current block based on time
109
136
  const currentBlock = useMemo(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
@@ -130,78 +157,38 @@ export function useDocPlayback(
130
157
  return Math.min(1, currentTime / script.duration);
131
158
  }, [script, currentTime]);
132
159
 
133
- // Track block transitions.
134
- // In render mode, transitions are computed from blockTime so they stay
135
- // synchronized with the seekTo timeline. In normal mode, setTimeout drives
136
- // transitions at the browser's real-time clock speed.
137
- const _prevBlockRef = useMemo(
138
- () => currentBlock,
139
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on index, not block reference
140
- [currentBlockIndex],
141
- );
142
-
143
- useEffect(() => {
144
- if (!currentBlock || renderMode) return;
145
-
146
- // When block changes, trigger transition (real-time mode only)
147
- if (transitionState.previousBlock?.id !== currentBlock.id) {
148
- const transition = currentBlock.transition;
149
- const transitionDuration = transition?.duration || 0;
150
-
151
- if (transitionDuration > 0) {
152
- // Start transition
153
- setTransitionState({
154
- entering: true,
155
- exiting: true,
156
- previousBlock: transitionState.previousBlock,
157
- });
158
-
159
- // End transition after duration
160
- const timer = setTimeout(() => {
161
- setTransitionState({
162
- entering: false,
163
- exiting: false,
164
- previousBlock: currentBlock,
165
- });
166
- }, transitionDuration * 1000);
167
-
168
- return () => clearTimeout(timer);
169
- } else {
170
- // Instant cut
171
- setTransitionState({
172
- entering: false,
173
- exiting: false,
174
- previousBlock: currentBlock,
175
- });
176
- }
177
- }
178
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on currentBlock?.id only; reading transitionState.previousBlock without dep to avoid infinite loop
179
- }, [currentBlock?.id, renderMode]);
180
-
181
- // Render mode: track previous block via ref and compute transition from time
182
- const renderPrevBlockRef = useRef<Block | null>(null);
183
-
184
- useEffect(() => {
185
- if (!renderMode || !currentBlock) return;
186
-
187
- if (transitionState.previousBlock?.id !== currentBlock.id) {
188
- // Block changed — remember the old block for crossfade
189
- const oldPrev = transitionState.previousBlock;
190
- renderPrevBlockRef.current = oldPrev;
191
- // Store current block as the "last seen" for next transition
192
- setTransitionState((prev) => ({
193
- ...prev,
194
- previousBlock: currentBlock,
195
- }));
196
- }
197
- // eslint-disable-next-line react-hooks/exhaustive-deps -- same pattern: keyed on block identity change
198
- }, [currentBlock?.id, renderMode]);
199
-
200
- // In render mode, derive entering/exiting from blockTime
201
- const renderTransitionDuration = currentBlock?.transition?.duration || 0;
202
- const renderIsEntering =
203
- renderMode && renderTransitionDuration > 0 && blockTime < renderTransitionDuration;
204
- const renderIsExiting = renderIsEntering && renderPrevBlockRef.current !== null;
160
+ // ─── Transition tracking (synchronous — no effect lag) ──────────────
161
+ // `isEntering` is simply "we are within the block's entrance window"
162
+ // (blockTime < the transition's duration). Deriving it during render
163
+ // rather than flipping it in an effect a frame AFTER the block changes
164
+ // means a newly-active block renders WITH its entrance state on its very
165
+ // first frame. Otherwise the block paints once fully settled and then, a
166
+ // frame later, snaps back to the start of its entrance animation: the brief
167
+ // "flash then re-animate" seen between blocks. This runs identically for
168
+ // real-time playback and frame-seeked render (export) mode.
169
+ //
170
+ // The block we transitioned FROM (for the crossfade) is tracked with refs
171
+ // updated during render — the standard "previous value" pattern — so the
172
+ // outgoing block is known on the SAME frame the new block becomes active
173
+ // (an effect would lag a frame and drop the crossfade's first frames).
174
+ const outgoingBlockRef = useRef<Block | null>(null);
175
+ const activeBlockIdRef = useRef<string | null>(null);
176
+ const lastRenderedBlockRef = useRef<Block | null>(null);
177
+ if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
178
+ outgoingBlockRef.current = lastRenderedBlockRef.current;
179
+ activeBlockIdRef.current = currentBlock.id;
180
+ }
181
+ lastRenderedBlockRef.current = currentBlock;
182
+
183
+ const transitionDuration = currentBlock?.transition
184
+ ? resolveTransitionDuration(currentBlock.transition)
185
+ : 0;
186
+ const isEntering = !!currentBlock && transitionDuration > 0 && blockTime < transitionDuration;
187
+ const outgoingBlock = outgoingBlockRef.current;
188
+ // Only crossfade a genuinely different outgoing block (guards restarts/seeks
189
+ // where the "previous" resolves to the same block).
190
+ const isExiting = isEntering && outgoingBlock != null && outgoingBlock.id !== currentBlock?.id;
191
+ const previousBlock = isExiting ? outgoingBlock : null;
205
192
 
206
193
  // Manual navigation
207
194
  const goToBlock = useCallback(
@@ -233,15 +220,9 @@ export function useDocPlayback(
233
220
  return {
234
221
  currentBlock,
235
222
  currentBlockIndex,
236
- previousBlock: renderMode
237
- ? renderIsExiting
238
- ? renderPrevBlockRef.current
239
- : null
240
- : transitionState.exiting
241
- ? transitionState.previousBlock
242
- : null,
243
- isEntering: renderMode ? renderIsEntering : transitionState.entering,
244
- isExiting: renderMode ? renderIsExiting : transitionState.exiting,
223
+ previousBlock,
224
+ isEntering,
225
+ isExiting,
245
226
  blockTime,
246
227
  blockProgress,
247
228
  docProgress,