@bendyline/squisq-editor-react 1.6.2 → 2.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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -12,7 +12,8 @@
12
12
  * doc parse.
13
13
  *
14
14
  * Visual conventions match `VideoExportModal` from `@bendyline/squisq-
15
- * video-react` (cream / gold palette, inline styles, no external CSS).
15
+ * video-react`, with inline theme tokens so the body-level portal follows
16
+ * the editor's light/dark chrome scheme.
16
17
  */
17
18
 
18
19
  import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
@@ -25,6 +26,8 @@ import { buildTimingJson, encodeTimingJson, timingPathFor } from './timingJson.j
25
26
 
26
27
  // ── Types ──────────────────────────────────────────────────────────
27
28
 
29
+ export type RecorderColorScheme = 'light' | 'dark';
30
+
28
31
  export interface RecorderModalProps {
29
32
  /** Required — recordings are written here. */
30
33
  mediaProvider: MediaProvider;
@@ -37,6 +40,8 @@ export interface RecorderModalProps {
37
40
  container?: ContentContainer | null;
38
41
  /** Initial capture source. Defaults to `'mic'` (narration). */
39
42
  initialMode?: RecorderSource;
43
+ /** Light/dark chrome scheme. Defaults to `'light'`. */
44
+ colorScheme?: RecorderColorScheme;
40
45
  /** Called after the modal is dismissed (save or cancel). */
41
46
  onClose: () => void;
42
47
  /**
@@ -77,9 +82,34 @@ const overlayStyle: CSSProperties = {
77
82
  zIndex: 10000,
78
83
  };
79
84
 
85
+ /**
86
+ * The recorder is normally portaled to `document.body`, outside the editor
87
+ * shell's CSS-variable scope. Copy the scheme onto a fresh scope and provide
88
+ * inline fallbacks so the standalone RecorderModal/RecorderButton exports are
89
+ * themed even when a host has not loaded the editor stylesheet.
90
+ */
91
+ function recorderThemeStyle(colorScheme: RecorderColorScheme): CSSProperties {
92
+ const dark = colorScheme === 'dark';
93
+ return {
94
+ colorScheme,
95
+ '--squisq-recorder-surface': `var(--squisq-bg, ${dark ? '#1f2937' : '#fffdf7'})`,
96
+ '--squisq-recorder-input': `var(--squisq-input-bg, ${dark ? '#374151' : '#fff'})`,
97
+ '--squisq-recorder-border': `var(--squisq-border, ${dark ? '#4b5563' : '#c9b98a'})`,
98
+ '--squisq-recorder-text': `var(--squisq-text, ${dark ? '#e5e7eb' : '#4a3c1f'})`,
99
+ '--squisq-recorder-muted': `var(--squisq-text-muted, ${dark ? '#9ca3af' : '#5a4a2a'})`,
100
+ '--squisq-recorder-accent': 'var(--squisq-accent, #8b6914)',
101
+ '--squisq-recorder-accent-text': '#fff',
102
+ '--squisq-recorder-danger': dark ? '#dc4c4c' : '#b33a3a',
103
+ '--squisq-recorder-danger-border': dark ? '#ef6a6a' : '#902929',
104
+ '--squisq-recorder-error-bg': dark ? '#3f151b' : '#fceeee',
105
+ '--squisq-recorder-error-border': dark ? '#7f1d1d' : '#d88a8a',
106
+ '--squisq-recorder-error-text': dark ? '#fecdd3' : '#8c2a2a',
107
+ } as CSSProperties;
108
+ }
109
+
80
110
  const modalStyle: CSSProperties = {
81
- background: '#FFFDF7',
82
- border: '1px solid #c9b98a',
111
+ background: 'var(--squisq-recorder-surface)',
112
+ border: '1px solid var(--squisq-recorder-border)',
83
113
  borderRadius: 0,
84
114
  padding: '24px 28px',
85
115
  width: 'min(560px, calc(100vw - 48px))',
@@ -87,14 +117,14 @@ const modalStyle: CSSProperties = {
87
117
  overflowY: 'auto',
88
118
  boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
89
119
  fontFamily: 'system-ui, -apple-system, sans-serif',
90
- color: '#4a3c1f',
120
+ color: 'var(--squisq-recorder-text)',
91
121
  };
92
122
 
93
123
  const titleStyle: CSSProperties = {
94
124
  margin: '0 0 16px 0',
95
125
  fontSize: 18,
96
126
  fontWeight: 600,
97
- color: '#2d2310',
127
+ color: 'var(--squisq-recorder-text)',
98
128
  };
99
129
 
100
130
  const labelStyle: CSSProperties = {
@@ -102,7 +132,7 @@ const labelStyle: CSSProperties = {
102
132
  fontSize: 13,
103
133
  fontWeight: 500,
104
134
  marginBottom: 4,
105
- color: '#5a4a2a',
135
+ color: 'var(--squisq-recorder-text)',
106
136
  };
107
137
 
108
138
  const inputStyle: CSSProperties = {
@@ -110,10 +140,10 @@ const inputStyle: CSSProperties = {
110
140
  padding: '6px 8px',
111
141
  fontSize: 13,
112
142
  fontFamily: 'inherit',
113
- border: '1px solid #c9b98a',
143
+ border: '1px solid var(--squisq-recorder-border)',
114
144
  borderRadius: 0,
115
- background: '#fff',
116
- color: '#4a3c1f',
145
+ background: 'var(--squisq-recorder-input)',
146
+ color: 'var(--squisq-recorder-text)',
117
147
  marginBottom: 12,
118
148
  boxSizing: 'border-box',
119
149
  };
@@ -130,9 +160,9 @@ const btnPrimary: CSSProperties = {
130
160
  fontFamily: 'inherit',
131
161
  fontWeight: 500,
132
162
  cursor: 'pointer',
133
- background: '#8B6914',
134
- color: '#fff',
135
- border: '1px solid #7a5c10',
163
+ background: 'var(--squisq-recorder-accent)',
164
+ color: 'var(--squisq-recorder-accent-text)',
165
+ border: '1px solid var(--squisq-recorder-accent)',
136
166
  borderRadius: 0,
137
167
  };
138
168
 
@@ -142,16 +172,16 @@ const btnSecondary: CSSProperties = {
142
172
  fontFamily: 'inherit',
143
173
  fontWeight: 500,
144
174
  cursor: 'pointer',
145
- background: '#E8DFC6',
146
- color: '#4a3c1f',
147
- border: '1px solid #c9b98a',
175
+ background: 'var(--squisq-recorder-input)',
176
+ color: 'var(--squisq-recorder-text)',
177
+ border: '1px solid var(--squisq-recorder-border)',
148
178
  borderRadius: 0,
149
179
  };
150
180
 
151
181
  const btnDanger: CSSProperties = {
152
182
  ...btnPrimary,
153
- background: '#B33A3A',
154
- borderColor: '#902929',
183
+ background: 'var(--squisq-recorder-danger)',
184
+ borderColor: 'var(--squisq-recorder-danger-border)',
155
185
  };
156
186
 
157
187
  const toggleRowStyle: CSSProperties = {
@@ -166,17 +196,17 @@ const toggleBase: CSSProperties = {
166
196
  fontFamily: 'inherit',
167
197
  cursor: 'pointer',
168
198
  background: 'transparent',
169
- color: '#5a4a2a',
170
- border: '1px solid #c9b98a',
199
+ color: 'var(--squisq-recorder-text)',
200
+ border: '1px solid var(--squisq-recorder-border)',
171
201
  borderRadius: 999,
172
202
  };
173
203
 
174
204
  const toggleActive: CSSProperties = {
175
205
  ...toggleBase,
176
- color: '#fffdf5',
206
+ color: 'var(--squisq-recorder-accent-text)',
177
207
  fontWeight: 600,
178
- background: '#8B6914',
179
- borderColor: '#8B6914',
208
+ background: 'var(--squisq-recorder-accent)',
209
+ borderColor: 'var(--squisq-recorder-accent)',
180
210
  };
181
211
 
182
212
  const previewBoxStyle: CSSProperties = {
@@ -196,21 +226,21 @@ const previewBoxStyle: CSSProperties = {
196
226
  const audioMeterStyle: CSSProperties = {
197
227
  width: '100%',
198
228
  height: 56,
199
- background: '#F2EBD9',
200
- border: '1px solid #c9b98a',
229
+ background: 'var(--squisq-recorder-input)',
230
+ border: '1px solid var(--squisq-recorder-border)',
201
231
  marginBottom: 12,
202
232
  display: 'flex',
203
233
  alignItems: 'center',
204
234
  justifyContent: 'center',
205
- color: '#5a4a2a',
235
+ color: 'var(--squisq-recorder-muted)',
206
236
  fontSize: 13,
207
237
  fontVariantNumeric: 'tabular-nums',
208
238
  };
209
239
 
210
240
  const errorStyle: CSSProperties = {
211
- background: '#FCEEEE',
212
- border: '1px solid #D88A8A',
213
- color: '#8C2A2A',
241
+ background: 'var(--squisq-recorder-error-bg)',
242
+ border: '1px solid var(--squisq-recorder-error-border)',
243
+ color: 'var(--squisq-recorder-error-text)',
214
244
  padding: '8px 10px',
215
245
  fontSize: 13,
216
246
  marginBottom: 12,
@@ -223,6 +253,20 @@ const buttonRowStyle: CSSProperties = {
223
253
  marginTop: 8,
224
254
  };
225
255
 
256
+ const summaryStyle: CSSProperties = {
257
+ margin: '0 0 12px 0',
258
+ fontSize: 12,
259
+ color: 'var(--squisq-recorder-muted)',
260
+ };
261
+
262
+ const recordingStatusStyle: CSSProperties = {
263
+ fontSize: 13,
264
+ fontVariantNumeric: 'tabular-nums',
265
+ marginBottom: 12,
266
+ color: 'var(--squisq-recorder-accent)',
267
+ fontWeight: 600,
268
+ };
269
+
226
270
  // ── Helpers ────────────────────────────────────────────────────────
227
271
 
228
272
  function formatDurationMs(ms: number): string {
@@ -293,6 +337,7 @@ export function RecorderModal({
293
337
  mediaProvider,
294
338
  container = null,
295
339
  initialMode = 'mic',
340
+ colorScheme = 'light',
296
341
  onClose,
297
342
  onSave,
298
343
  }: RecorderModalProps) {
@@ -471,7 +516,14 @@ export function RecorderModal({
471
516
  };
472
517
 
473
518
  return (
474
- <div style={overlayStyle} role="dialog" aria-modal="true" aria-label="Record media">
519
+ <div
520
+ className="squisq-editor-shell squisq-recorder-overlay"
521
+ data-theme={colorScheme}
522
+ style={{ ...overlayStyle, ...recorderThemeStyle(colorScheme) }}
523
+ role="dialog"
524
+ aria-modal="true"
525
+ aria-label="Record media"
526
+ >
475
527
  <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
476
528
  <h2 style={titleStyle}>Record media</h2>
477
529
 
@@ -493,9 +545,7 @@ export function RecorderModal({
493
545
  })}
494
546
  </div>
495
547
 
496
- <p style={{ margin: '0 0 12px 0', fontSize: 12, color: '#5a4a2a' }}>
497
- {captureSummary(micOn, video)}
498
- </p>
548
+ <p style={summaryStyle}>{captureSummary(micOn, video)}</p>
499
549
 
500
550
  {recorder.error && <div style={errorStyle}>{recorder.error.message}</div>}
501
551
  {saveError && <div style={errorStyle}>{saveError}</div>}
@@ -579,6 +629,7 @@ export function RecorderModal({
579
629
  >
580
630
  <input
581
631
  type="checkbox"
632
+ style={{ accentColor: 'var(--squisq-recorder-accent)' }}
582
633
  checked={includeSystemAudio}
583
634
  onChange={(e) => setIncludeSystemAudio(e.target.checked)}
584
635
  disabled={recorder.state === 'recording' || recorder.state === 'requesting'}
@@ -602,15 +653,7 @@ export function RecorderModal({
602
653
 
603
654
  {/* Live duration during recording */}
604
655
  {recorder.state === 'recording' && !isAudioOnly && (
605
- <div
606
- style={{
607
- fontSize: 13,
608
- fontVariantNumeric: 'tabular-nums',
609
- marginBottom: 12,
610
- color: '#8B6914',
611
- fontWeight: 600,
612
- }}
613
- >
656
+ <div style={recordingStatusStyle}>
614
657
  ● Recording {formatDurationMs(recorder.durationMs)}
615
658
  </div>
616
659
  )}
@@ -12,7 +12,11 @@ import { useCallback, useState } from 'react';
12
12
  import { createPortal } from 'react-dom';
13
13
  import type { MediaProvider } from '@bendyline/squisq/schemas';
14
14
  import type { ContentContainer } from '@bendyline/squisq/storage';
15
- import { RecorderModal, type RecorderSaveResult } from './RecorderModal.js';
15
+ import {
16
+ RecorderModal,
17
+ type RecorderColorScheme,
18
+ type RecorderSaveResult,
19
+ } from './RecorderModal.js';
16
20
  import { Icon } from '../Icon';
17
21
  import type { RecorderSource } from './hooks/useMediaRecorder.js';
18
22
 
@@ -20,6 +24,8 @@ export interface RecorderPanelProps {
20
24
  mediaProvider: MediaProvider;
21
25
  container?: ContentContainer | null;
22
26
  initialMode?: RecorderSource;
27
+ /** Light/dark chrome scheme copied onto the portaled modal. */
28
+ colorScheme?: RecorderColorScheme;
23
29
  onSave?: (result: RecorderSaveResult) => void;
24
30
  /** ARIA / tooltip label. Defaults to `'Record media'`. */
25
31
  tooltip?: string;
@@ -31,6 +37,7 @@ export function RecorderPanel({
31
37
  mediaProvider,
32
38
  container = null,
33
39
  initialMode = 'mic',
40
+ colorScheme = 'light',
34
41
  onSave,
35
42
  tooltip = 'Record media',
36
43
  className,
@@ -57,6 +64,7 @@ export function RecorderPanel({
57
64
  mediaProvider={mediaProvider}
58
65
  container={container}
59
66
  initialMode={initialMode}
67
+ colorScheme={colorScheme}
60
68
  onClose={handleClose}
61
69
  onSave={(result) => {
62
70
  onSave?.(result);
@@ -173,6 +173,9 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
173
173
  const startTimestampRef = useRef<number | null>(null);
174
174
  const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
175
175
  const stopResolversRef = useRef<Array<(blob: Blob | null) => void>>([]);
176
+ const stopPromiseRef = useRef<Promise<Blob | null> | null>(null);
177
+ const requestPromiseRef = useRef<Promise<void> | null>(null);
178
+ const lifecycleRef = useRef(0);
176
179
 
177
180
  // Stable copy of options for callbacks that read them late. Re-evaluated
178
181
  // each render, but each callback closes over the ref so we don't have
@@ -222,9 +225,13 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
222
225
  }, [clearTicker]);
223
226
 
224
227
  const cancel = useCallback(() => {
228
+ lifecycleRef.current += 1;
225
229
  const rec = recorderRef.current;
226
230
  if (rec && rec.state !== 'inactive') {
227
231
  try {
232
+ rec.ondataavailable = null;
233
+ rec.onstop = null;
234
+ rec.onerror = null;
228
235
  rec.stop();
229
236
  } catch {
230
237
  // Ignore — we're tearing down anyway.
@@ -237,6 +244,8 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
237
244
  startTimestampRef.current = null;
238
245
  // Any in-flight stop() promises won't get a blob.
239
246
  stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
247
+ stopPromiseRef.current = null;
248
+ requestPromiseRef.current = null;
240
249
  setBlob(null);
241
250
  setDurationMs(0);
242
251
  setError(null);
@@ -244,66 +253,89 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
244
253
  }, [clearTicker, releaseStream]);
245
254
 
246
255
  const request = useCallback(async () => {
256
+ if (requestPromiseRef.current) return requestPromiseRef.current;
257
+ if (recorderRef.current?.stream.active) return;
247
258
  if (!supportsMediaRecorder()) {
248
259
  const err = new Error('MediaRecorder is not supported in this environment.');
249
260
  setError(err);
250
261
  setState('error');
251
262
  throw err;
252
263
  }
253
- if (state === 'recording' || state === 'stopping') {
254
- // Don't start a parallel acquisition while one is in flight.
255
- return;
256
- }
257
- setError(null);
258
- setState('requesting');
259
- try {
260
- const source = optionsRef.current.source ?? 'mic';
261
- const { stream: nextStream, dispose } = await acquireStream(source, optionsRef.current);
262
- const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
263
- const recorderOptions: MediaRecorderOptions = {};
264
- if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
265
- if (optionsRef.current.bitsPerSecond) {
266
- recorderOptions.bitsPerSecond = optionsRef.current.bitsPerSecond;
267
- }
268
- const recorder = new MediaRecorder(nextStream, recorderOptions);
264
+ const lifecycle = ++lifecycleRef.current;
265
+ const requestPromise = (async () => {
266
+ setError(null);
267
+ setState('requesting');
268
+ let acquired: { stream: MediaStream; dispose: () => void } | null = null;
269
+ try {
270
+ const source = optionsRef.current.source ?? 'mic';
271
+ acquired = await acquireStream(source, optionsRef.current);
272
+ const { stream: nextStream, dispose } = acquired;
273
+ if (lifecycle !== lifecycleRef.current) {
274
+ nextStream.getTracks().forEach((track) => track.stop());
275
+ dispose();
276
+ return;
277
+ }
278
+ const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
279
+ const recorderOptions: MediaRecorderOptions = {};
280
+ if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
281
+ if (optionsRef.current.bitsPerSecond) {
282
+ recorderOptions.bitsPerSecond = optionsRef.current.bitsPerSecond;
283
+ }
284
+ const recorder = new MediaRecorder(nextStream, recorderOptions);
269
285
 
270
- recorder.ondataavailable = (e) => {
271
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
272
- };
273
- recorder.onstop = () => {
274
- // The recorded MIME type is authoritative once data is in hand —
275
- // some browsers down-negotiate the format (e.g. drop codec hint).
276
- const recordedType = recorder.mimeType || resolved.mimeType || 'application/octet-stream';
277
- const finalBlob = new Blob(chunksRef.current, { type: recordedType });
278
- chunksRef.current = [];
279
- setBlob(finalBlob);
280
- setState('stopped');
281
- clearTicker();
282
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(finalBlob));
283
- };
284
- recorder.onerror = (event) => {
285
- const detail = (event as unknown as { error?: DOMException }).error;
286
- const err = detail instanceof Error ? detail : new Error('Recorder error');
287
- setError(err);
288
- setState('error');
289
- clearTicker();
290
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
291
- };
286
+ recorder.ondataavailable = (e) => {
287
+ if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
288
+ };
289
+ recorder.onstop = () => {
290
+ if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
291
+ // The recorded MIME type is authoritative once data is in hand —
292
+ // some browsers down-negotiate the format (e.g. drop codec hint).
293
+ const recordedType = recorder.mimeType || resolved.mimeType || 'application/octet-stream';
294
+ const finalBlob = new Blob(chunksRef.current, { type: recordedType });
295
+ chunksRef.current = [];
296
+ setBlob(finalBlob);
297
+ setState('stopped');
298
+ clearTicker();
299
+ stopResolversRef.current.splice(0).forEach((resolve) => resolve(finalBlob));
300
+ stopPromiseRef.current = null;
301
+ };
302
+ recorder.onerror = (event) => {
303
+ if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
304
+ const detail = (event as unknown as { error?: DOMException }).error;
305
+ const err = detail instanceof Error ? detail : new Error('Recorder error');
306
+ setError(err);
307
+ setState('error');
308
+ clearTicker();
309
+ stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
310
+ stopPromiseRef.current = null;
311
+ };
292
312
 
293
- recorderRef.current = recorder;
294
- disposeStreamRef.current = dispose;
295
- setStream(nextStream);
296
- setFormat(resolved);
297
- setBlob(null);
298
- setDurationMs(0);
299
- setState('ready');
300
- } catch (err: unknown) {
301
- const normalized = err instanceof Error ? err : new Error('Stream acquisition failed');
302
- setError(normalized);
303
- setState('error');
304
- throw normalized;
305
- }
306
- }, [state, clearTicker]);
313
+ recorderRef.current = recorder;
314
+ disposeStreamRef.current = dispose;
315
+ setStream(nextStream);
316
+ setFormat(resolved);
317
+ setBlob(null);
318
+ setDurationMs(0);
319
+ setState('ready');
320
+ acquired = null;
321
+ } catch (err: unknown) {
322
+ if (acquired) {
323
+ acquired.stream.getTracks().forEach((track) => track.stop());
324
+ acquired.dispose();
325
+ }
326
+ const normalized = err instanceof Error ? err : new Error('Stream acquisition failed');
327
+ if (lifecycle === lifecycleRef.current) {
328
+ setError(normalized);
329
+ setState('error');
330
+ }
331
+ throw normalized;
332
+ } finally {
333
+ if (lifecycle === lifecycleRef.current) requestPromiseRef.current = null;
334
+ }
335
+ })();
336
+ requestPromiseRef.current = requestPromise;
337
+ return requestPromise;
338
+ }, [clearTicker]);
307
339
 
308
340
  const start = useCallback(() => {
309
341
  const rec = recorderRef.current;
@@ -329,12 +361,13 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
329
361
  }, [clearTicker]);
330
362
 
331
363
  const stop = useCallback((): Promise<Blob | null> => {
364
+ if (stopPromiseRef.current) return stopPromiseRef.current;
332
365
  const rec = recorderRef.current;
333
366
  if (!rec || rec.state === 'inactive') {
334
367
  return Promise.resolve(blob);
335
368
  }
336
369
  setState('stopping');
337
- return new Promise<Blob | null>((resolve) => {
370
+ const stopPromise = new Promise<Blob | null>((resolve) => {
338
371
  stopResolversRef.current.push(resolve);
339
372
  try {
340
373
  rec.stop();
@@ -346,6 +379,11 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
346
379
  stopResolversRef.current.splice(0).forEach((r) => r(null));
347
380
  }
348
381
  });
382
+ stopPromiseRef.current = stopPromise;
383
+ void stopPromise.finally(() => {
384
+ if (stopPromiseRef.current === stopPromise) stopPromiseRef.current = null;
385
+ });
386
+ return stopPromise;
349
387
  }, [blob, clearTicker]);
350
388
 
351
389
  // Final unmount cleanup — make sure we don't leak the camera light /
@@ -360,9 +398,14 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
360
398
  useEffect(() => {
361
399
  const pendingResolvers = stopResolversRef.current;
362
400
  return () => {
401
+ lifecycleRef.current += 1;
402
+ requestPromiseRef.current = null;
363
403
  const rec = recorderRef.current;
364
404
  if (rec && rec.state !== 'inactive') {
365
405
  try {
406
+ rec.ondataavailable = null;
407
+ rec.onstop = null;
408
+ rec.onerror = null;
366
409
  rec.stop();
367
410
  } catch {
368
411
  // ignore
@@ -371,6 +414,7 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
371
414
  releaseStream();
372
415
  clearTicker();
373
416
  pendingResolvers.splice(0).forEach((resolve) => resolve(null));
417
+ stopPromiseRef.current = null;
374
418
  };
375
419
  }, [releaseStream, clearTicker]);
376
420
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * resolveBlockVisual — resolves a doc block into a renderable card (with
3
- * `layers`) via the shared preview pipeline (`buildPreviewDoc` `getLayers`).
3
+ * `layers`) via the shared preview pipeline and canonical materializer.
4
4
  *
5
5
  * Kept in its own module (rather than alongside `BlockThumbnail` in
6
6
  * `TimelineBlockPreview.tsx`) so that component file exports only components,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Block, DocBlock, Doc, ViewportConfig, Theme } from '@bendyline/squisq/schemas';
11
- import { getLayers, type RenderContext } from '@bendyline/squisq/doc';
11
+ import { materializeBlockLayers, type MaterializeBlockLayersOptions } from '@bendyline/squisq/doc';
12
12
  import { buildPreviewDoc } from './buildPreviewDoc';
13
13
 
14
14
  /**
@@ -24,8 +24,14 @@ export function resolveBlockVisual(
24
24
  try {
25
25
  const slide = buildPreviewDoc({ ...doc, blocks: [block] }).blocks[0] as DocBlock | undefined;
26
26
  if (!slide) return null;
27
- const ctx: RenderContext = { blockIndex: 0, totalBlocks: 1, theme, viewport };
28
- const layers = getLayers(slide, ctx);
27
+ const ctx: MaterializeBlockLayersOptions = {
28
+ blockIndex: 0,
29
+ totalBlocks: 1,
30
+ theme,
31
+ viewport,
32
+ customTemplates: doc.customTemplates,
33
+ };
34
+ const { layers } = materializeBlockLayers(slide, ctx);
29
35
  return { ...(slide as unknown as Block), layers };
30
36
  } catch {
31
37
  return null;