@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/types.ts ADDED
@@ -0,0 +1,486 @@
1
+ /**
2
+ * editor-core / types — the host-agnostic boundary for Montaj's carousel
3
+ * editor.
4
+ *
5
+ * The editor module knows nothing about *where* a project lives or *how* it is
6
+ * transported. The host application (Montaj's own UI, or a Next.js client app
7
+ * like mission-control) supplies an `EditorAdapter` that implements load / save
8
+ * / subscribe / render / image-resolution against whatever transport it owns.
9
+ *
10
+ * The canonical project/slide/element shapes live in `./schema` (the package's
11
+ * own editor-facing schema). Internally we alias `EditorProject` to `Project`
12
+ * so the ported reducer/hook/tests keep their original naming. These names are
13
+ * re-exported from this module so the package's internal modules can import
14
+ * them from `../types`; the public barrel (index.ts) sources the schema types
15
+ * from `./schema` directly to avoid duplicate-export conflicts.
16
+ */
17
+ import type { ReactElement, ReactNode } from 'react'
18
+ import type {
19
+ EditorProject as Project,
20
+ Slide,
21
+ CarouselElement,
22
+ ImageElement,
23
+ OverlayElement,
24
+ } from './schema'
25
+
26
+ // ── Overlay compiler ─────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * A compiled overlay factory: given the current frame/fps/durationFrames and
30
+ * the overlay's runtime props, returns a React element (or null on error).
31
+ * Matches the signature produced by `lib/overlay-eval`'s `compileOverlay`.
32
+ */
33
+ export type OverlayFactory = (
34
+ frame: number,
35
+ fps: number,
36
+ durationFrames: number,
37
+ props: Record<string, unknown>,
38
+ ) => ReactElement | null
39
+
40
+ // ── Re-exported canonical carousel types ─────────────────────────────────────
41
+ // schema.ts is the single source of truth. Consumers of editor-core import
42
+ // these from here so the module presents one coherent surface.
43
+ export type { Project, Slide, CarouselElement, ImageElement, OverlayElement }
44
+
45
+ // ── Render ───────────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * A single frame of render progress. Discriminated on `type`:
49
+ * - 'log' — a human-readable progress line.
50
+ * - 'done' — terminal success; `outputPath` is the rendered artifact location
51
+ * (a host-resolvable path/URL — Montaj returns a workspace path,
52
+ * a Hub client may return a media URL).
53
+ * - 'error' — terminal failure; `message` describes what went wrong.
54
+ */
55
+ export type RenderEvent =
56
+ | { type: 'log'; message: string }
57
+ | { type: 'done'; outputPath: string }
58
+ | { type: 'error'; message: string }
59
+
60
+ /**
61
+ * Options for a render request. Kept intentionally minimal — Montaj's render
62
+ * endpoint (`POST /api/projects/:id/render`) takes no body today, so `scale`
63
+ * is the only forward-looking knob and is optional. Hosts ignore fields they
64
+ * don't support.
65
+ */
66
+ export interface RenderOptions {
67
+ /** Output scale multiplier (1 = native resolution). */
68
+ scale?: number
69
+ }
70
+
71
+ // ── Overlay library types ─────────────────────────────────────────────────────
72
+ // Copied verbatim from Montaj's `ui/src/lib/api.ts` so the package owns the
73
+ // shape the editor consumes. A host's overlay-listing endpoints return these;
74
+ // the adapter wraps whatever transport produces them.
75
+
76
+ /**
77
+ * A single declared prop on an overlay template. `type` drives the input
78
+ * control the editor renders; `default` seeds an unset value.
79
+ */
80
+ export interface GlobalOverlayProp {
81
+ name: string
82
+ type: 'string' | 'int' | 'float' | 'bool' | 'color'
83
+ default?: unknown
84
+ description?: string
85
+ }
86
+
87
+ /**
88
+ * A reusable overlay template the host exposes (global, system, or
89
+ * profile-scoped). `jsxPath` is the host-resolvable path to the JSX template
90
+ * the adapter feeds to `compileOverlay`; `group` is an optional UI grouping;
91
+ * `empty` flags a placeholder group with no concrete overlay yet.
92
+ */
93
+ export interface GlobalOverlay {
94
+ name: string
95
+ description: string
96
+ props: GlobalOverlayProp[]
97
+ jsxPath: string
98
+ group?: string
99
+ empty?: boolean
100
+ }
101
+
102
+ // ── Version history (optional capability) ─────────────────────────────────────
103
+
104
+ /**
105
+ * A single entry in a project's version history. The editor-relevant slice of
106
+ * Montaj's `ProjectVersion` (ui/src/lib/types/schema.ts): a content-addressed
107
+ * `hash` to restore by, a human-readable `message`, and a `timestamp`. The
108
+ * adapter maps the host's richer shape down to this.
109
+ */
110
+ export interface VersionEntry {
111
+ hash: string
112
+ message: string
113
+ timestamp: string
114
+ }
115
+
116
+ // ── Waveform chunks (optional capability) ─────────────────────────────────────
117
+
118
+ /**
119
+ * One rendered waveform-image chunk for an audio track. `path` is a
120
+ * host-resolvable image path (route through `fileUrl` to display); `start`/`end`
121
+ * are source-file seconds the chunk covers. Copied verbatim from Montaj's former
122
+ * `lib/audio-waveform.ts` so the package owns the shape the timeline consumes.
123
+ */
124
+ export interface WaveformChunk {
125
+ path: string
126
+ start: number
127
+ end: number
128
+ }
129
+
130
+ // ── Media (optional capability) ───────────────────────────────────────────────
131
+
132
+ /**
133
+ * Scope for a media-library query. Grounded in mission-control's
134
+ * `UseMediaListScope`: media is either project-scoped or drawn from the host's
135
+ * universal/global library.
136
+ */
137
+ export type MediaScope =
138
+ | { kind: 'universal' }
139
+ | { kind: 'project'; projectId: string }
140
+
141
+ /**
142
+ * A minimal media-library item. Hosts may carry more fields, but the editor
143
+ * only relies on these: an id to reference, a resolvable URL to display, a
144
+ * MIME content type, and an optional display name.
145
+ */
146
+ export interface MediaItem {
147
+ id: string
148
+ /** A directly displayable URL (presigned, workspace, or otherwise host-resolved). */
149
+ url: string
150
+ contentType: string
151
+ name?: string
152
+ }
153
+
154
+ // ── Adapter ────────────────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * The contract a host implements to drive the editor. All transport,
158
+ * authentication, and URL-shape concerns live behind this interface; the
159
+ * editor calls only these methods.
160
+ *
161
+ * Generic over the host's concrete project type `P` (constrained to the
162
+ * editor-facing `Project` = EditorProject). Montaj instantiates it with its
163
+ * full `Project`; a host with no extra fields gets the default `Project`. This
164
+ * lets the host's pipeline fields survive load→edit→save round-trips at the
165
+ * type level without casts.
166
+ */
167
+ export interface EditorAdapter<P extends Project = Project> {
168
+ /** Fetch the full project by id. */
169
+ loadProject(id: string): Promise<P>
170
+
171
+ /** Persist the full project. Mirrors Montaj's `PUT /api/projects/:id`. */
172
+ saveProject(id: string, project: P): Promise<void>
173
+
174
+ /**
175
+ * Subscribe to live project frames (e.g. an SSE stream). `onFrame` is invoked
176
+ * with each fresh project snapshot. Returns an unsubscribe function the
177
+ * editor calls on teardown.
178
+ */
179
+ subscribe(id: string, onFrame: (project: P) => void): () => void
180
+
181
+ /**
182
+ * Start a render and stream progress as an async iterable of `RenderEvent`s.
183
+ * The iterable completes after a terminal 'done' or 'error' event.
184
+ */
185
+ render(id: string, opts?: RenderOptions): AsyncIterable<RenderEvent>
186
+
187
+ /**
188
+ * Resolve an `ImageElement` to a directly displayable URL. This is the host's
189
+ * job because the resolution rule differs per host:
190
+ * - Montaj returns a workspace/files URL (e.g. `/api/files?path=...`).
191
+ * - Hub clients resolve a `mediaId` → presigned URL.
192
+ * The editor never assumes a URL shape — it always routes through here.
193
+ */
194
+ resolveImageSrc(element: ImageElement): string
195
+
196
+ /**
197
+ * Optional: list media available to the editor in the given scope. Hosts
198
+ * without a media library omit this; the editor must feature-detect it.
199
+ */
200
+ listMedia?(scope: MediaScope): Promise<MediaItem[]>
201
+
202
+ /**
203
+ * Compile a JSX overlay template file into an `OverlayFactory`.
204
+ * The host supplies this because the compilation pipeline (Babel, fetch
205
+ * strategy, caching) is host-specific. The editor-core preview component
206
+ * receives it as a prop; nothing inside editor-core imports the host's
207
+ * compiler directly.
208
+ */
209
+ compileOverlay(template: string): Promise<OverlayFactory>
210
+
211
+ /**
212
+ * List the host's global (workspace-wide) overlay templates. The assembled
213
+ * editor's overlay picker reads these. Maps to Montaj's `GET /api/overlays`.
214
+ */
215
+ listGlobalOverlays(): Promise<GlobalOverlay[]>
216
+
217
+ /**
218
+ * List the host's built-in/system overlay templates. Maps to Montaj's
219
+ * `GET /api/overlays/system`.
220
+ */
221
+ listSystemOverlays(): Promise<GlobalOverlay[]>
222
+
223
+ /**
224
+ * Upload a file and return a host-resolvable path/ref. When `projectId` is
225
+ * given, the host should store it inside the project (so it stays
226
+ * self-contained); otherwise a shared/upload location is used. Maps to
227
+ * Montaj's `POST /api/projects/:id/upload-asset` (or `POST /api/upload`).
228
+ */
229
+ uploadFile(file: File, projectId?: string): Promise<string>
230
+
231
+ /**
232
+ * Map a host path to a directly fetchable URL. Synchronous because hosts
233
+ * derive it by string transform (Montaj: `/api/files?path=...`). Distinct
234
+ * from `resolveImageSrc`, which takes an `ImageElement` and applies element
235
+ * resolution rules; `fileUrl` is the raw path→URL primitive.
236
+ */
237
+ fileUrl(path: string): string
238
+
239
+ /**
240
+ * Optional: list overlay templates scoped to a named profile. Hosts without
241
+ * profile-scoped overlays omit this. Maps to Montaj's
242
+ * `GET /api/profiles/:name/overlays`.
243
+ */
244
+ listProfileOverlays?(profileName: string): Promise<GlobalOverlay[]>
245
+
246
+ /**
247
+ * Optional: host environment info the editor may surface (e.g. the root
248
+ * skill path for authoring overlays). Maps to Montaj's `GET /api/info`.
249
+ */
250
+ getInfo?(): Promise<{ root_skill_path?: string }>
251
+
252
+ /**
253
+ * Optional: generate an image from a prompt and return its host path. Hosts
254
+ * without AI image generation omit this; the editor feature-detects it.
255
+ */
256
+ generateImage?(prompt: string, projectId: string): Promise<{ path: string }>
257
+
258
+ /**
259
+ * Optional: watch a host file path for changes, invoking `onChange` whenever
260
+ * the file is rewritten. Returns an unsubscribe function. The editor uses this
261
+ * to auto-recover an overlay preview when its source is edited on disk. Hosts
262
+ * without a file-watch transport omit this; the editor simply doesn't watch
263
+ * (no fallback EventSource). Montaj wires this to its `/api/files/stream` SSE.
264
+ */
265
+ watchFile?(path: string, onChange: () => void): () => void
266
+
267
+ /**
268
+ * Optional: resolve the host's default "static text" overlay template — the
269
+ * one the editor's "+ Text" button seeds. Returns null when the host has no
270
+ * such template (the editor then hides "+ Text"). Hosts without any system
271
+ * text overlay omit this entirely. Montaj implements it over
272
+ * `listSystemOverlays()` + its `static-text` matcher.
273
+ */
274
+ getDefaultTextOverlay?(): Promise<GlobalOverlay | null>
275
+
276
+ // ── Video editor capabilities (optional) ────────────────────────────────────
277
+ // Hosts driving the video editor implement these; carousel-only hosts omit
278
+ // them and the editor feature-detects their absence.
279
+
280
+ /**
281
+ * Optional: list the project's version history, newest-first. Maps to
282
+ * Montaj's `GET /api/projects/:id/versions`, mapped down to `VersionEntry`.
283
+ */
284
+ listVersionHistory?(id: string): Promise<VersionEntry[]>
285
+
286
+ /**
287
+ * Optional: restore the project to a prior version by `hash`, returning the
288
+ * restored project. Maps to Montaj's
289
+ * `POST /api/projects/:id/versions/:hash/restore`.
290
+ */
291
+ restoreVersion?(id: string, hash: string): Promise<P>
292
+
293
+ /**
294
+ * Optional: produce rendered waveform-image chunks for an audio track. The
295
+ * editor passes the project id, the track id (used to namespace the output
296
+ * cache), the track's source path, and an optional chunk duration in seconds.
297
+ * The host renders/caches the chunks and returns their resolvable paths. Maps
298
+ * to Montaj's `waveform_image` step.
299
+ */
300
+ getWaveformChunks?(
301
+ projectId: string,
302
+ trackId: string,
303
+ trackSrc: string,
304
+ chunkDurationS?: number,
305
+ ): Promise<WaveformChunk[]>
306
+
307
+ /**
308
+ * Optional: invalidate the host's compiled-overlay cache. When `src` is given,
309
+ * only that entry is dropped; hosts may treat a missing `src` as a no-op or a
310
+ * full clear. Maps to Montaj's `clearOverlayCache` in `lib/overlay-eval`.
311
+ */
312
+ clearOverlayCache?(src?: string): void
313
+
314
+ /**
315
+ * Optional: resolve the template identifier that `compileOverlay` should
316
+ * receive for a given caption style name. The mapping is host-specific —
317
+ * Montaj uses `/api/caption-template/<style>`; other hosts may differ.
318
+ * When absent the editor renders no captions (graceful no-op). Hosts without
319
+ * caption support omit this entirely.
320
+ */
321
+ resolveCaptionTemplate?(style: string): string
322
+ }
323
+
324
+ // ── Theme ────────────────────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * A flat token record describing the editor's visual language. The host passes
328
+ * one of these (or relies on the Montaj default). `applyTheme` (in theme.ts)
329
+ * writes these tokens as CSS custom properties so styling stays declarative and
330
+ * host-overridable.
331
+ */
332
+ export interface EditorTheme {
333
+ colors: {
334
+ /** Outermost canvas/page background. */
335
+ background: string
336
+ /** Raised panels, toolbars, inspectors. */
337
+ surface: string
338
+ /** Primary interactive/brand accent. */
339
+ accent: string
340
+ /** Default text color. */
341
+ text: string
342
+ /** Hairline/divider color. */
343
+ border: string
344
+ /** Selection outline / active-element highlight. */
345
+ selection: string
346
+ }
347
+ fonts: {
348
+ sans: string
349
+ serif?: string
350
+ display?: string
351
+ }
352
+ /** Border-radius scale, smallest → largest. */
353
+ radii: {
354
+ sm: string
355
+ md: string
356
+ lg: string
357
+ }
358
+ /**
359
+ * Spacing scale keyed by step. Indices follow a 4px-base rhythm (matching
360
+ * Tailwind's `1`=4px, `2`=8px, …). Values are CSS lengths.
361
+ */
362
+ spacing: Record<number, string>
363
+ }
364
+
365
+ // ── Host-injected UI ──────────────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Optional UI the host injects into editor slots — e.g. a "Publish to Hub"
369
+ * button in the toolbar, or app-specific export controls.
370
+ */
371
+ export interface EditorSlots {
372
+ /** Rendered into the editor toolbar's action area. */
373
+ toolbarActions?: ReactNode
374
+ /** Rendered into the editor's export/render action area. */
375
+ exportActions?: ReactNode
376
+ /** Rendered into the editor's assets/media panel area. */
377
+ assetsPanel?: ReactNode
378
+ /**
379
+ * Rendered in the pending/empty view in place of the default
380
+ * "Message your agent to start" copy. Hosts use this to surface live agent
381
+ * progress (Montaj feeds its SSE log line here); absent → default copy shows.
382
+ */
383
+ pendingStatus?: ReactNode
384
+ /**
385
+ * Rendered in the right sidebar below the version-history panel — in the same
386
+ * position ReviewView showed "Previous runs". The host supplies the concrete
387
+ * Montaj run-snapshot list (reading `project.history: RunSnapshot[]` and
388
+ * offering a "Restore this run" action via `onProjectChange`). The package
389
+ * never reads `project.history` or `RunSnapshot` — those are host-only types.
390
+ * Absent → nothing is rendered in that slot.
391
+ */
392
+ runHistory?: ReactNode
393
+ }
394
+
395
+ // ── Top-level component props ──────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Props for the carousel editor component. Controlled shape: the host owns the
399
+ * `project` and is notified of edits via `onProjectChange`. The adapter drives
400
+ * transport; theme and slots are optional, and `readOnly` disables mutation.
401
+ */
402
+ export interface CarouselEditorProps<P extends Project = Project> {
403
+ project: P
404
+ adapter: EditorAdapter<P>
405
+ onProjectChange?: (p: P) => void
406
+ theme?: EditorTheme
407
+ slots?: EditorSlots
408
+ readOnly?: boolean
409
+ /**
410
+ * Editor-only set of element ids to hide from the interactive canvas. The host
411
+ * owns this state; the package never persists it (hidden elements are omitted
412
+ * from the canvas render only, never from `saveProject`). Lets a host
413
+ * temporarily hide a scrim/background to position overlays beneath it.
414
+ */
415
+ hiddenElementIds?: string[]
416
+ /**
417
+ * Invoked when the user toggles the selected element's editor-visibility via
418
+ * the property-panel eye button. The host updates its hidden-set and reflects
419
+ * it back through `hiddenElementIds`. Absent → no eye toggle is rendered.
420
+ */
421
+ onToggleElementVisibility?: (elementId: string) => void
422
+ /**
423
+ * Invoked whenever the selected element changes — with the element, or `null`
424
+ * when selection clears. Lets a host drive selection-aware chrome (e.g. a
425
+ * "regenerate image" action in a toolbar slot that targets the current
426
+ * selection). The package keeps owning selection state.
427
+ */
428
+ onSelectionChange?: (element: CarouselElement | null) => void
429
+ }
430
+
431
+ /**
432
+ * Props for the video editor component. Mirrors `CarouselEditorProps` —
433
+ * controlled `project` + `onProjectChange`, adapter-driven transport, optional
434
+ * theme/slots/readOnly — and adds `onBackToSetup`, the host-supplied callback
435
+ * the editor invokes when the user leaves the editor for the project's setup
436
+ * view.
437
+ */
438
+ export interface VideoEditorProps<P extends Project = Project> {
439
+ project: P
440
+ adapter: EditorAdapter<P>
441
+ onProjectChange?: (p: P) => void
442
+ theme?: EditorTheme
443
+ slots?: EditorSlots
444
+ readOnly?: boolean
445
+ onBackToSetup?: () => void
446
+
447
+ // ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
448
+ // The clip/audio inspector and the subcut-regeneration tool read host-only
449
+ // fields (regenQueue, storyboard, the host's full Project) the package types
450
+ // don't know. The editor surfaces them as render-props it threads/renders so
451
+ // those components can stay host-side; the editor stays Montaj-agnostic.
452
+
453
+ /**
454
+ * Render-prop seam for the host's clip/audio inspector (Montaj's
455
+ * ClipInspectModal). The editor owns the "which item is being inspected"
456
+ * state — it derives `ctx.item` from the timeline's `onInspectClip` /
457
+ * `onInspectAudio` callbacks (a Montaj-agnostic `{ kind, id }` selector, not
458
+ * a project entity) and passes a close callback. Absent → no inspector.
459
+ */
460
+ renderClipInspector?: (ctx: {
461
+ item: { kind: 'clip' | 'audio'; id: string }
462
+ onClose: () => void
463
+ }) => ReactNode
464
+
465
+ /**
466
+ * Render-prop seam for the host's subcut-regeneration tool (Montaj's
467
+ * SubcutRegenTool). Threaded straight through to the timeline, which owns the
468
+ * open/close trigger (the per-clip Scissors button). Called with the clip id
469
+ * and a close callback. Absent → the subcut tool isn't rendered.
470
+ */
471
+ renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
472
+
473
+ /**
474
+ * Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
475
+ * ai_video projects). Threaded to the timeline. The package never reads
476
+ * `projectType`.
477
+ */
478
+ regenEnabled?: boolean
479
+
480
+ /**
481
+ * Host-computed predicate driving the per-clip "queued" badge (Montaj:
482
+ * project.regenQueue membership). Threaded to the timeline. The package never
483
+ * reads `regenQueue`.
484
+ */
485
+ isClipQueued?: (itemId: string) => boolean
486
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Button } from '../button'
4
+
5
+ describe('vendored Button', () => {
6
+ it('renders its children', () => {
7
+ render(<Button>Click me</Button>)
8
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
9
+ })
10
+
11
+ it('fires onClick when clicked', () => {
12
+ const onClick = vi.fn()
13
+ render(<Button onClick={onClick}>Go</Button>)
14
+ fireEvent.click(screen.getByRole('button', { name: 'Go' }))
15
+ expect(onClick).toHaveBeenCalledTimes(1)
16
+ })
17
+ })
@@ -0,0 +1,32 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { cn } from './utils'
3
+
4
+ const badgeVariants = cva(
5
+ 'inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold',
6
+ {
7
+ variants: {
8
+ variant: {
9
+ pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
10
+ draft: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
11
+ final: 'bg-emerald-100 text-emerald-700 dark:bg-green-900/50 dark:text-green-300',
12
+ default: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
13
+ },
14
+ },
15
+ defaultVariants: { variant: 'default' },
16
+ },
17
+ )
18
+
19
+ export interface BadgeProps
20
+ extends React.HTMLAttributes<HTMLSpanElement>,
21
+ VariantProps<typeof badgeVariants> {}
22
+
23
+ export function Badge({ className, variant, ...props }: BadgeProps) {
24
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />
25
+ }
26
+
27
+ export function StatusBadge({ status }: { status: string }) {
28
+ const variant = ['pending', 'draft', 'final'].includes(status)
29
+ ? (status as 'pending' | 'draft' | 'final')
30
+ : 'default'
31
+ return <Badge variant={variant}>{status}</Badge>
32
+ }
@@ -0,0 +1,32 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { cn } from './utils'
3
+
4
+ const buttonVariants = cva(
5
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:pointer-events-none disabled:opacity-50',
6
+ {
7
+ variants: {
8
+ variant: {
9
+ default: 'bg-blue-600 text-white hover:bg-blue-700',
10
+ secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
11
+ ghost: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100',
12
+ danger: 'bg-red-600 text-white hover:bg-red-700',
13
+ outline: 'border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
14
+ },
15
+ size: {
16
+ default: 'h-9 px-4 py-2',
17
+ sm: 'h-7 px-3 text-xs',
18
+ lg: 'h-11 px-6',
19
+ icon: 'h-9 w-9',
20
+ },
21
+ },
22
+ defaultVariants: { variant: 'default', size: 'default' },
23
+ },
24
+ )
25
+
26
+ export interface ButtonProps
27
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
28
+ VariantProps<typeof buttonVariants> {}
29
+
30
+ export function Button({ className, variant, size, ...props }: ButtonProps) {
31
+ return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
32
+ }
@@ -0,0 +1,16 @@
1
+ // Vendored UI primitives — self-contained copies of Montaj's shadcn-style
2
+ // components so the package has no `@/` host dependency. Tailwind classes are
3
+ // preserved verbatim; the host supplies the matching Tailwind theme.
4
+ export { cn } from './utils'
5
+ export { Button } from './button'
6
+ export type { ButtonProps } from './button'
7
+ export { Input } from './input'
8
+ export type { InputProps } from './input'
9
+ export { Label } from './label'
10
+ export { Select } from './select'
11
+ export type { SelectProps } from './select'
12
+ export { Switch } from './switch'
13
+ export { Textarea } from './textarea'
14
+ export type { TextareaProps } from './textarea'
15
+ export { Badge, StatusBadge } from './badge'
16
+ export type { BadgeProps } from './badge'
@@ -0,0 +1,15 @@
1
+ import { cn } from './utils'
2
+
3
+ export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
4
+
5
+ export function Input({ className, ...props }: InputProps) {
6
+ return (
7
+ <input
8
+ className={cn(
9
+ 'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500',
10
+ className,
11
+ )}
12
+ {...props}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,10 @@
1
+ import { cn } from './utils'
2
+
3
+ export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
4
+ return (
5
+ <label
6
+ className={cn('text-xs font-medium text-gray-400 leading-none', className)}
7
+ {...props}
8
+ />
9
+ )
10
+ }
@@ -0,0 +1,23 @@
1
+ import { cn } from './utils'
2
+
3
+ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
4
+ options: Array<{ value: string; label: string }>
5
+ }
6
+
7
+ export function Select({ className, options, ...props }: SelectProps) {
8
+ return (
9
+ <select
10
+ className={cn(
11
+ 'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100',
12
+ className,
13
+ )}
14
+ {...props}
15
+ >
16
+ {options.map((o) => (
17
+ <option key={o.value} value={o.value}>
18
+ {o.label}
19
+ </option>
20
+ ))}
21
+ </select>
22
+ )
23
+ }
@@ -0,0 +1,31 @@
1
+ import { cn } from './utils'
2
+
3
+ interface SwitchProps {
4
+ checked: boolean
5
+ onCheckedChange: (v: boolean) => void
6
+ className?: string
7
+ disabled?: boolean
8
+ }
9
+
10
+ export function Switch({ checked, onCheckedChange, className, disabled }: SwitchProps) {
11
+ return (
12
+ <button
13
+ role="switch"
14
+ aria-checked={checked}
15
+ disabled={disabled}
16
+ onClick={() => onCheckedChange(!checked)}
17
+ className={cn(
18
+ 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50',
19
+ checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-700',
20
+ className,
21
+ )}
22
+ >
23
+ <span
24
+ className={cn(
25
+ 'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
26
+ checked ? 'translate-x-4' : 'translate-x-1',
27
+ )}
28
+ />
29
+ </button>
30
+ )
31
+ }