@bycrux/editor 0.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 (89) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  7. package/src/carousel/AddElementMenu.tsx +211 -0
  8. package/src/carousel/CarouselEditor.tsx +545 -0
  9. package/src/carousel/CarouselRenderModal.tsx +243 -0
  10. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  11. package/src/carousel/OverlayPicker.tsx +145 -0
  12. package/src/carousel/ReadOnlySlide.tsx +90 -0
  13. package/src/carousel/SlideCanvas.tsx +637 -0
  14. package/src/carousel/SlidePropertyPanel.tsx +387 -0
  15. package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
  16. package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
  17. package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
  18. package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -0
  19. package/src/crop/CanvasCropOverlay.tsx +193 -0
  20. package/src/crop/__tests__/crop-math.test.ts +174 -0
  21. package/src/crop/crop-math.ts +125 -0
  22. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  23. package/src/gestures/helpers/drag.ts +24 -0
  24. package/src/gestures/helpers/element-transform.ts +15 -0
  25. package/src/gestures/helpers/resize.ts +60 -0
  26. package/src/gestures/helpers/rotate.ts +44 -0
  27. package/src/gestures/helpers/snap.ts +64 -0
  28. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  29. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  30. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  31. package/src/gestures/index.ts +16 -0
  32. package/src/index.ts +136 -0
  33. package/src/lib/google-fonts.ts +28 -0
  34. package/src/overlays/contract.ts +41 -0
  35. package/src/preview/OverlayPreview.tsx +196 -0
  36. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  37. package/src/schema.ts +201 -0
  38. package/src/state/__tests__/project-reducer.test.ts +957 -0
  39. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  40. package/src/state/mutation-queue.ts +62 -0
  41. package/src/state/project-reducer.ts +328 -0
  42. package/src/state/use-project-state.ts +442 -0
  43. package/src/test-setup.ts +1 -0
  44. package/src/text/FontPicker.tsx +218 -0
  45. package/src/text/InlineTextEditor.tsx +92 -0
  46. package/src/text/TextFormattingToolbar.tsx +248 -0
  47. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  48. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  49. package/src/theme.ts +93 -0
  50. package/src/types.ts +486 -0
  51. package/src/ui/__tests__/button.test.tsx +17 -0
  52. package/src/ui/badge.tsx +32 -0
  53. package/src/ui/button.tsx +32 -0
  54. package/src/ui/index.ts +16 -0
  55. package/src/ui/input.tsx +15 -0
  56. package/src/ui/label.tsx +10 -0
  57. package/src/ui/select.tsx +23 -0
  58. package/src/ui/switch.tsx +31 -0
  59. package/src/ui/textarea.tsx +15 -0
  60. package/src/ui/utils.ts +7 -0
  61. package/src/video/RenderModal.tsx +252 -0
  62. package/src/video/VersionPanel.tsx +83 -0
  63. package/src/video/VideoEditor.tsx +508 -0
  64. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  65. package/src/video/__tests__/captionRepair.test.ts +134 -0
  66. package/src/video/__tests__/cuts.test.ts +198 -0
  67. package/src/video/captionRepair.ts +41 -0
  68. package/src/video/cuts.ts +369 -0
  69. package/src/video/design-canvas.ts +11 -0
  70. package/src/video/preview/CaptionPreview.tsx +83 -0
  71. package/src/video/preview/CarouselPreview.tsx +35 -0
  72. package/src/video/preview/OverlayItemsLayer.tsx +584 -0
  73. package/src/video/preview/PreviewPlayer.tsx +178 -0
  74. package/src/video/preview/useDragOverlay.ts +167 -0
  75. package/src/video/preview/useVideoPlayback.ts +761 -0
  76. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  77. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  78. package/src/video/timeline/EditableSegment.tsx +30 -0
  79. package/src/video/timeline/Scrubber.tsx +184 -0
  80. package/src/video/timeline/Timeline.tsx +375 -0
  81. package/src/video/timeline/TimelineContext.ts +25 -0
  82. package/src/video/timeline/TranscriptModal.tsx +63 -0
  83. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  84. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  85. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  86. package/src/video/timeline/multiSelectOps.ts +157 -0
  87. package/src/video/timeline/useItemDragDrop.ts +190 -0
  88. package/src/video/timeline/useTimelineZoom.ts +48 -0
  89. package/src/video/timeline/utils.ts +17 -0
package/src/index.ts ADDED
@@ -0,0 +1,136 @@
1
+ // @bycrux/editor — public API.
2
+ //
3
+ // The package owns the host-agnostic carousel editor: the editor-facing schema,
4
+ // the adapter/theme contracts, and the host-agnostic PIECES (state, gestures,
5
+ // crop, text, preview, overlays). Hosts (Montaj's UI, Hub clients) import from
6
+ // here and supply an EditorAdapter to drive it.
7
+
8
+ // ── Schema (single source of truth for project/slide/element shapes) ──────────
9
+ export type {
10
+ Word,
11
+ AudioTrack,
12
+ CaptionSegment,
13
+ Captions,
14
+ VisualItem,
15
+ Asset,
16
+ ImageElement,
17
+ OverlayElement,
18
+ CarouselElement,
19
+ Slide,
20
+ EditorProject,
21
+ } from './schema'
22
+
23
+ // ── Contracts (adapter, theme, render, media, component props) ────────────────
24
+ // Schema types are sourced from './schema' above; here we export only the
25
+ // symbols types.ts itself owns, plus the `Project` alias (= EditorProject) that
26
+ // the ported state/reducer code is typed against.
27
+ export type {
28
+ Project,
29
+ OverlayFactory,
30
+ RenderEvent,
31
+ RenderOptions,
32
+ MediaScope,
33
+ MediaItem,
34
+ GlobalOverlay,
35
+ GlobalOverlayProp,
36
+ VersionEntry,
37
+ WaveformChunk,
38
+ EditorAdapter,
39
+ EditorTheme,
40
+ EditorSlots,
41
+ CarouselEditorProps,
42
+ VideoEditorProps,
43
+ } from './types'
44
+
45
+ // ── Video editor pure helpers ─────────────────────────────────────────────────
46
+ export {
47
+ applyCutToTracks,
48
+ applyCutToItem,
49
+ collapseGaps,
50
+ splitAtTime,
51
+ } from './video/cuts'
52
+ export type { Cut } from './video/cuts'
53
+ export { getOverlayDesignCanvas } from './video/design-canvas'
54
+
55
+ // ── Theme ─────────────────────────────────────────────────────────────────────
56
+ export { defaultMontajTheme, applyTheme } from './theme'
57
+
58
+ // ── State ───────────────────────────────────────────────────────────────────
59
+ export { useProjectState } from './state/use-project-state'
60
+ export type { Connection, UseProjectState } from './state/use-project-state'
61
+ export { projectReducer } from './state/project-reducer'
62
+ export type { Action, ProjectStatus } from './state/project-reducer'
63
+ export { createMutationQueue } from './state/mutation-queue'
64
+ export type { MutationQueue } from './state/mutation-queue'
65
+
66
+ // ── Gestures ──────────────────────────────────────────────────────────────────
67
+ export * from './gestures'
68
+
69
+ // ── Crop ──────────────────────────────────────────────────────────────────────
70
+ export {
71
+ renderedSourceRect,
72
+ fractionToWrapperPx,
73
+ wrapperPxToFraction,
74
+ applyCropHandleDrag,
75
+ } from './crop/crop-math'
76
+ export type {
77
+ RenderedRect,
78
+ CropFraction,
79
+ WrapperPxRect,
80
+ CropHandle,
81
+ } from './crop/crop-math'
82
+ export { CanvasCropOverlay } from './crop/CanvasCropOverlay'
83
+ export type { CanvasCropOverlayProps } from './crop/CanvasCropOverlay'
84
+
85
+ // ── Text ──────────────────────────────────────────────────────────────────────
86
+ export {
87
+ FONT_OPTIONS,
88
+ findFontOption,
89
+ FontFamilyPicker,
90
+ FontSizePicker,
91
+ } from './text/FontPicker'
92
+ export type { FontOption } from './text/FontPicker'
93
+ export { InlineTextEditor } from './text/InlineTextEditor'
94
+ export type { InlineTextEditorProps } from './text/InlineTextEditor'
95
+ export {
96
+ HEX_PATTERN,
97
+ isColorProp,
98
+ isBold,
99
+ isItalic,
100
+ nextCase,
101
+ isStyleProp,
102
+ nonColorTextEntries,
103
+ TextFormattingToolbar,
104
+ } from './text/TextFormattingToolbar'
105
+ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
106
+
107
+ // ── Preview ─────────────────────────────────────────────────────────────────
108
+ export { OverlayPreview } from './preview/OverlayPreview'
109
+ export type { OverlayPreviewProps } from './preview/OverlayPreview'
110
+
111
+ // ── Video preview ─────────────────────────────────────────────────────────────
112
+ export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
113
+ export { default as CarouselPreview } from './video/preview/CarouselPreview'
114
+ export { default as OverlayItemsLayer } from './video/preview/OverlayItemsLayer'
115
+ export { useVideoPlayback } from './video/preview/useVideoPlayback'
116
+ export { useDragOverlay } from './video/preview/useDragOverlay'
117
+ export type { Corner, DragType } from './video/preview/useDragOverlay'
118
+
119
+ // ── Overlays ──────────────────────────────────────────────────────────────────
120
+ export {
121
+ STANDARD_TEXT_PROPS,
122
+ getSupportedProps,
123
+ readPropAsString,
124
+ } from './overlays/contract'
125
+
126
+ // ── Assembled editors ─────────────────────────────────────────────────────────
127
+ export { default as CarouselEditor } from './carousel/CarouselEditor'
128
+ export { default as VideoEditor } from './video/VideoEditor'
129
+
130
+ // ── Public carousel sub-components ────────────────────────────────────────────
131
+ // Hosts consume these beyond the assembled editor — Montaj's preview/caption
132
+ // components render SlideCanvas thumbnails and wrap overlays in the boundary.
133
+ export { default as SlideCanvas, resolveAsset } from './carousel/SlideCanvas'
134
+ export { default as OverlayErrorBoundary } from './carousel/OverlayErrorBoundary'
135
+ export { default as ReadOnlySlide } from './carousel/ReadOnlySlide'
136
+ export type { ReadOnlySlideProps } from './carousel/ReadOnlySlide'
@@ -0,0 +1,28 @@
1
+ // Shared Google Fonts loader.
2
+ //
3
+ // Injects a Google Fonts stylesheet <link> for the declared family specs so the
4
+ // editor's preview/render uses the same glyphs and metrics the renderer
5
+ // (bundle.js) fetches. Used by both the video overlay layer and the carousel
6
+ // overlay render path. Resilient by design: a font-load failure must never
7
+ // break the render (we only append a <link>; the browser handles the fetch).
8
+
9
+ // Track Google Fonts URLs already injected so we don't add the same <link>
10
+ // twice when multiple overlays declare overlapping fonts. Keyed by the full
11
+ // stylesheet URL — the same URL never produces a duplicate fetch from
12
+ // Chromium regardless, but the duplicate <link> tags would still clutter
13
+ // document.head across long editing sessions.
14
+ const __injectedFontUrls = new Set<string>()
15
+
16
+ export function ensureGoogleFontsLoaded(googleFonts: string[] | undefined): void {
17
+ if (!googleFonts?.length) return
18
+ // Match the format bundle.js uses for the render pipeline so preview and
19
+ // render fetch identical CSS (and identical glyphs / metrics).
20
+ const url = `https://fonts.googleapis.com/css2?${googleFonts.map((f) => `family=${f}`).join('&')}&display=swap`
21
+ if (__injectedFontUrls.has(url)) return
22
+ __injectedFontUrls.add(url)
23
+ if (typeof document === 'undefined') return
24
+ const link = document.createElement('link')
25
+ link.rel = 'stylesheet'
26
+ link.href = url
27
+ document.head.appendChild(link)
28
+ }
@@ -0,0 +1,41 @@
1
+ import type { OverlayElement } from '../types'
2
+
3
+ // The 9-prop editable-text contract. Single source of truth on the FE for
4
+ // which prop keys the floating text toolbar knows how to write. Mirrors
5
+ // hub/backend/src/modules/mcp/overlay-contract.ts REQUIRED_PROPS.
6
+ export const STANDARD_TEXT_PROPS: ReadonlySet<string> = new Set([
7
+ 'text', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle',
8
+ 'color', 'textAlign', 'textTransform', 'bgColor',
9
+ ])
10
+
11
+ /**
12
+ * Returns the subset of STANDARD_TEXT_PROPS present (non-null) on the
13
+ * element. Toolbar controls render only when their target prop is in this set.
14
+ *
15
+ * Forgiving on value type: numeric values (e.g. `fontSize: 64`) count as
16
+ * present. The toolbar reads them via String(value) and writes back as
17
+ * strings — fixing the round-trip silently rather than blocking the operator
18
+ * with a "this overlay isn't editable" message. Per the resolved product
19
+ * question, this prefers UX over strictness; non-string values on contract
20
+ * props are still flagged upstream by hub.write_overlay's validator.
21
+ */
22
+ export function getSupportedProps(element: OverlayElement): Set<string> {
23
+ const props = element.overlay.props
24
+ const supported = new Set<string>()
25
+ for (const key of STANDARD_TEXT_PROPS) {
26
+ const value = props[key]
27
+ if (value !== undefined && value !== null) supported.add(key)
28
+ }
29
+ return supported
30
+ }
31
+
32
+ /**
33
+ * Read a contract prop's current value as a string, coercing numbers. Used
34
+ * by the toolbar's read paths so the displayed control value matches what's
35
+ * on the element regardless of its stored type.
36
+ */
37
+ export function readPropAsString(element: OverlayElement, key: string): string {
38
+ const value = element.overlay.props[key]
39
+ if (value === undefined || value === null) return ''
40
+ return String(value)
41
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * editor-core/preview/OverlayPreview
3
+ *
4
+ * Host-agnostic React component that compiles and renders a JSX overlay
5
+ * template. The overlay compiler is injected via the `compileOverlay` prop so
6
+ * this component has no dependency on any host module (no import from
7
+ * '@/lib/overlay-eval'). The host wires in the compiler from its adapter.
8
+ *
9
+ * States:
10
+ * - Compiling → `loading` node (default: spinner with role="status").
11
+ * - Compile or runtime error → `errorState` node (default: red badge with role="alert").
12
+ * - Success → factory output element.
13
+ */
14
+
15
+ import React, { useEffect, useState } from 'react'
16
+ import type { OverlayFactory } from '../types'
17
+
18
+ // ── Defaults ─────────────────────────────────────────────────────────────────
19
+
20
+ function DefaultSpinner(): React.ReactElement {
21
+ return (
22
+ <div
23
+ role="status"
24
+ aria-label="Loading overlay"
25
+ style={{
26
+ position: 'absolute',
27
+ inset: 0,
28
+ display: 'flex',
29
+ alignItems: 'center',
30
+ justifyContent: 'center',
31
+ color: 'rgba(255, 255, 255, 0.65)',
32
+ }}
33
+ >
34
+ <svg
35
+ width="36"
36
+ height="36"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ strokeWidth="2"
41
+ strokeLinecap="round"
42
+ >
43
+ <g>
44
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
45
+ <animateTransform
46
+ attributeName="transform"
47
+ type="rotate"
48
+ from="0 12 12"
49
+ to="360 12 12"
50
+ dur="0.9s"
51
+ repeatCount="indefinite"
52
+ />
53
+ </g>
54
+ </svg>
55
+ </div>
56
+ )
57
+ }
58
+
59
+ function DefaultErrorState(): React.ReactElement {
60
+ return (
61
+ <div
62
+ role="alert"
63
+ style={{
64
+ position: 'absolute',
65
+ inset: 0,
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ padding: 16,
70
+ }}
71
+ >
72
+ <div
73
+ style={{
74
+ display: 'inline-flex',
75
+ alignItems: 'center',
76
+ gap: 6,
77
+ padding: '4px 10px',
78
+ borderRadius: 6,
79
+ background: 'rgba(220, 38, 38, 0.15)',
80
+ color: 'rgb(220, 38, 38)',
81
+ fontSize: 12,
82
+ fontFamily: 'system-ui, -apple-system, sans-serif',
83
+ fontWeight: 500,
84
+ }}
85
+ >
86
+ <svg
87
+ width="14"
88
+ height="14"
89
+ viewBox="0 0 24 24"
90
+ fill="none"
91
+ stroke="currentColor"
92
+ strokeWidth="2"
93
+ strokeLinecap="round"
94
+ strokeLinejoin="round"
95
+ >
96
+ <circle cx="12" cy="12" r="10" />
97
+ <line x1="12" y1="8" x2="12" y2="12" />
98
+ <line x1="12" y1="16" x2="12.01" y2="16" />
99
+ </svg>
100
+ <span>overlay error</span>
101
+ </div>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ // ── Props ─────────────────────────────────────────────────────────────────────
107
+
108
+ export interface OverlayPreviewProps {
109
+ /**
110
+ * Host-supplied compiler. Receives a template path and returns a compiled
111
+ * OverlayFactory. Injected from the adapter so editor-core never imports
112
+ * the host's overlay-eval module directly.
113
+ */
114
+ compileOverlay: (template: string) => Promise<OverlayFactory>
115
+
116
+ /**
117
+ * Path to the overlay template file. Passed to the injected compileOverlay.
118
+ * Matches OverlayElement.overlay.template from Montaj's schema.
119
+ */
120
+ template: string
121
+
122
+ /**
123
+ * Runtime props forwarded to the overlay factory.
124
+ * Matches OverlayElement.overlay.props.
125
+ */
126
+ props: Record<string, unknown>
127
+
128
+ /**
129
+ * Frame number to render. Matches OverlayElement.frame or the caller's
130
+ * scrubber position.
131
+ */
132
+ frame: number
133
+
134
+ /** Frames per second. Passed to the factory alongside frame. */
135
+ fps: number
136
+
137
+ /**
138
+ * Duration in frames. Passed to the factory as `durationFrames`.
139
+ * Mirrors how SlideCanvas derives duration from element.overlay.props.duration.
140
+ */
141
+ duration: number
142
+
143
+ /** Shown while compileOverlay is in-flight. Default: spinner. */
144
+ loading?: React.ReactNode
145
+
146
+ /** Shown on compile error or factory runtime error. Default: red badge. */
147
+ errorState?: React.ReactNode
148
+ }
149
+
150
+ // ── Component ─────────────────────────────────────────────────────────────────
151
+
152
+ export function OverlayPreview({
153
+ compileOverlay,
154
+ template,
155
+ props,
156
+ frame,
157
+ fps,
158
+ duration,
159
+ loading,
160
+ errorState,
161
+ }: OverlayPreviewProps): React.ReactElement {
162
+ const [factory, setFactory] = useState<OverlayFactory | null>(null)
163
+ const [error, setError] = useState<Error | null>(null)
164
+
165
+ useEffect(() => {
166
+ let cancelled = false
167
+ // Reset state when template changes so the loading spinner re-appears.
168
+ setFactory(null)
169
+ setError(null)
170
+
171
+ compileOverlay(template)
172
+ .then((f) => {
173
+ if (!cancelled) setFactory(() => f)
174
+ })
175
+ .catch((e) => {
176
+ if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)))
177
+ })
178
+
179
+ return () => {
180
+ cancelled = true
181
+ }
182
+ }, [template])
183
+
184
+ const loadingNode = loading ?? <DefaultSpinner />
185
+ const errorNode = errorState ?? <DefaultErrorState />
186
+
187
+ if (error) return <>{errorNode}</>
188
+ if (!factory) return <>{loadingNode}</>
189
+
190
+ try {
191
+ const out = factory(frame, fps, duration, props)
192
+ return out ?? <>{errorNode}</>
193
+ } catch {
194
+ return <>{errorNode}</>
195
+ }
196
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * editor-core/preview/OverlayPreview — unit tests.
3
+ *
4
+ * The overlay compiler is injected via the `compileOverlay` prop so no module
5
+ * mock is needed. Each test passes its own fake compiler directly.
6
+ */
7
+
8
+ import React from 'react'
9
+ import { render, screen, waitFor } from '@testing-library/react'
10
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
11
+ import type { OverlayFactory } from '../../types'
12
+
13
+ // ── Import component under test ──────────────────────────────────────────────
14
+
15
+ import { OverlayPreview } from '../OverlayPreview'
16
+
17
+ // ── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ /** Factory that returns a simple div with a test id. */
20
+ const trivialFactory: OverlayFactory = (_frame, _fps, _duration, _props) =>
21
+ React.createElement('div', { 'data-testid': 'overlay-output' }, 'hello overlay')
22
+
23
+ /** A compileOverlay prop that always resolves with the given factory. */
24
+ const makeCompiler =
25
+ (factory: OverlayFactory) =>
26
+ (_template: string): Promise<OverlayFactory> =>
27
+ Promise.resolve(factory)
28
+
29
+ /** A compileOverlay prop that always rejects with the given error. */
30
+ const makeFailingCompiler =
31
+ (message: string) =>
32
+ (_template: string): Promise<OverlayFactory> =>
33
+ Promise.reject(new Error(message))
34
+
35
+ /** A compileOverlay prop that never resolves (simulates in-flight). */
36
+ const pendingCompiler = (_template: string): Promise<OverlayFactory> =>
37
+ new Promise(() => {})
38
+
39
+ const DEFAULT_PROPS = {
40
+ template: '/path/to/overlay.jsx',
41
+ props: { text: 'hi' },
42
+ frame: 0,
43
+ fps: 30,
44
+ duration: 60,
45
+ compileOverlay: makeCompiler(trivialFactory),
46
+ }
47
+
48
+ // ── Tests ─────────────────────────────────────────────────────────────────────
49
+
50
+ describe('OverlayPreview', () => {
51
+ beforeEach(() => {
52
+ vi.clearAllMocks()
53
+ })
54
+
55
+ afterEach(() => {
56
+ vi.restoreAllMocks()
57
+ })
58
+
59
+ it('shows a loading state while compiling', async () => {
60
+ render(<OverlayPreview {...DEFAULT_PROPS} compileOverlay={pendingCompiler} />)
61
+
62
+ expect(screen.getByRole('status')).toBeInTheDocument()
63
+ })
64
+
65
+ it('renders the factory output when compilation succeeds', async () => {
66
+ render(<OverlayPreview {...DEFAULT_PROPS} />)
67
+
68
+ await waitFor(() =>
69
+ expect(screen.getByTestId('overlay-output')).toBeInTheDocument(),
70
+ )
71
+ expect(screen.getByTestId('overlay-output')).toHaveTextContent('hello overlay')
72
+ })
73
+
74
+ it('passes frame/fps/duration/props to the factory', async () => {
75
+ const factorySpy = vi.fn(trivialFactory)
76
+
77
+ render(
78
+ <OverlayPreview
79
+ template="/t.jsx"
80
+ props={{ color: 'red' }}
81
+ frame={12}
82
+ fps={24}
83
+ duration={90}
84
+ compileOverlay={makeCompiler(factorySpy)}
85
+ />,
86
+ )
87
+
88
+ await waitFor(() => expect(factorySpy).toHaveBeenCalled())
89
+ expect(factorySpy).toHaveBeenCalledWith(12, 24, 90, expect.objectContaining({ color: 'red' }))
90
+ })
91
+
92
+ it('surfaces an error badge when compileOverlay rejects', async () => {
93
+ render(
94
+ <OverlayPreview
95
+ {...DEFAULT_PROPS}
96
+ compileOverlay={makeFailingCompiler('bad JSX syntax')}
97
+ />,
98
+ )
99
+
100
+ await waitFor(() =>
101
+ expect(screen.getByRole('alert')).toBeInTheDocument(),
102
+ )
103
+ const alert = screen.getByRole('alert')
104
+ expect(alert).toBeInTheDocument()
105
+ })
106
+
107
+ it('surfaces an error badge when the factory throws at render time', async () => {
108
+ const throwingFactory: OverlayFactory = () => {
109
+ throw new Error('runtime render error')
110
+ }
111
+
112
+ render(
113
+ <OverlayPreview
114
+ {...DEFAULT_PROPS}
115
+ compileOverlay={makeCompiler(throwingFactory)}
116
+ />,
117
+ )
118
+
119
+ await waitFor(() =>
120
+ expect(screen.getByRole('alert')).toBeInTheDocument(),
121
+ )
122
+ })
123
+
124
+ it('re-compiles when template changes', async () => {
125
+ const factory1: OverlayFactory = () =>
126
+ React.createElement('div', { 'data-testid': 'v1' }, 'v1')
127
+ const factory2: OverlayFactory = () =>
128
+ React.createElement('div', { 'data-testid': 'v2' }, 'v2')
129
+
130
+ const compilerSpy = vi.fn()
131
+ compilerSpy.mockResolvedValueOnce(factory1).mockResolvedValueOnce(factory2)
132
+
133
+ const { rerender } = render(
134
+ <OverlayPreview {...DEFAULT_PROPS} template="/overlay-v1.jsx" compileOverlay={compilerSpy} />,
135
+ )
136
+ await waitFor(() => expect(screen.getByTestId('v1')).toBeInTheDocument())
137
+
138
+ rerender(<OverlayPreview {...DEFAULT_PROPS} template="/overlay-v2.jsx" compileOverlay={compilerSpy} />)
139
+ await waitFor(() => expect(screen.getByTestId('v2')).toBeInTheDocument())
140
+
141
+ expect(compilerSpy).toHaveBeenCalledTimes(2)
142
+ })
143
+
144
+ it('accepts a custom loading node', async () => {
145
+ render(
146
+ <OverlayPreview
147
+ {...DEFAULT_PROPS}
148
+ compileOverlay={pendingCompiler}
149
+ loading={<div data-testid="custom-loading">Loading…</div>}
150
+ />,
151
+ )
152
+
153
+ expect(screen.getByTestId('custom-loading')).toBeInTheDocument()
154
+ })
155
+
156
+ it('accepts a custom errorState node', async () => {
157
+ render(
158
+ <OverlayPreview
159
+ {...DEFAULT_PROPS}
160
+ compileOverlay={makeFailingCompiler('boom')}
161
+ errorState={<div data-testid="custom-error">Error!</div>}
162
+ />,
163
+ )
164
+
165
+ await waitFor(() =>
166
+ expect(screen.getByTestId('custom-error')).toBeInTheDocument(),
167
+ )
168
+ })
169
+ })