@devbycrux/editor 0.1.0

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 (54) 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/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. package/src/ui/utils.ts +7 -0
@@ -0,0 +1,416 @@
1
+ // @vitest-environment jsdom
2
+ /// <reference types="vitest/globals" />
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import {
5
+ isBold,
6
+ isItalic,
7
+ nextCase,
8
+ isStyleProp,
9
+ isColorProp,
10
+ nonColorTextEntries,
11
+ TextFormattingToolbar,
12
+ } from '../TextFormattingToolbar'
13
+ import type { OverlayElement } from '../../types'
14
+
15
+ function makeOverlay(props: Record<string, unknown> = {}): OverlayElement {
16
+ return {
17
+ id: 'el-1',
18
+ type: 'overlay',
19
+ frame: 0,
20
+ x: 0,
21
+ y: 0,
22
+ w: 200,
23
+ h: 100,
24
+ rotation: 0,
25
+ overlay: { template: 'assets/test.jsx', props },
26
+ }
27
+ }
28
+
29
+ const SLIDE_ID = 'slide-1'
30
+
31
+ function renderToolbar(
32
+ props: Record<string, unknown>,
33
+ updateOverlayProp: ReturnType<typeof vi.fn>,
34
+ ) {
35
+ return render(
36
+ <TextFormattingToolbar
37
+ slideId={SLIDE_ID}
38
+ element={makeOverlay(props)}
39
+ updateOverlayProp={updateOverlayProp}
40
+ />,
41
+ )
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Pure helper unit tests
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe('isBold', () => {
49
+ it.each([
50
+ ['700', true],
51
+ ['bold', true],
52
+ [700, true],
53
+ ['600', true],
54
+ ['400', false],
55
+ [400, false],
56
+ ['normal', false],
57
+ [undefined, false],
58
+ ['', false],
59
+ ])('isBold(%s) → %s', (input, expected) => {
60
+ expect(isBold(input)).toBe(expected)
61
+ })
62
+ })
63
+
64
+ describe('isItalic', () => {
65
+ it.each([
66
+ ['italic', true],
67
+ ['normal', false],
68
+ ['oblique', false],
69
+ [undefined, false],
70
+ ])('isItalic(%s) → %s', (input, expected) => {
71
+ expect(isItalic(input)).toBe(expected)
72
+ })
73
+ })
74
+
75
+ describe('nextCase', () => {
76
+ it.each([
77
+ ['none', 'uppercase'],
78
+ ['uppercase', 'lowercase'],
79
+ ['lowercase', 'capitalize'],
80
+ ['capitalize', 'none'],
81
+ ['unknown', 'uppercase'],
82
+ ])('nextCase(%s) → %s', (input, expected) => {
83
+ expect(nextCase(input)).toBe(expected)
84
+ })
85
+ })
86
+
87
+ describe('isStyleProp', () => {
88
+ it.each([
89
+ ['fontWeight', true],
90
+ ['fontStyle', true],
91
+ ['textTransform', true],
92
+ ['textAlign', true],
93
+ ['title', false],
94
+ ['bg', false],
95
+ ['color', false],
96
+ ['', false],
97
+ ])('isStyleProp(%s) → %s', (key, expected) => {
98
+ expect(isStyleProp(key)).toBe(expected)
99
+ })
100
+ })
101
+
102
+ describe('isColorProp', () => {
103
+ it('returns true for hex values', () => {
104
+ expect(isColorProp('anything', '#ff0000')).toBe(true)
105
+ expect(isColorProp('anything', '#abc')).toBe(true)
106
+ expect(isColorProp('foo', '#AABBCC')).toBe(true)
107
+ })
108
+
109
+ it('returns true for name-matched color keys', () => {
110
+ expect(isColorProp('bg', 'white')).toBe(true)
111
+ expect(isColorProp('accent', 'blue')).toBe(true)
112
+ expect(isColorProp('foreground', 'dark')).toBe(true)
113
+ expect(isColorProp('background', 'light')).toBe(true)
114
+ expect(isColorProp('color', 'red')).toBe(true)
115
+ })
116
+
117
+ it('returns false for plain text values with non-matching keys', () => {
118
+ expect(isColorProp('title', 'Hello world')).toBe(false)
119
+ expect(isColorProp('subtitle', 'Some text')).toBe(false)
120
+ })
121
+ })
122
+
123
+ describe('nonColorTextEntries', () => {
124
+ it('filters out colors, style props, and non-strings', () => {
125
+ const result = nonColorTextEntries({
126
+ title: 'hi',
127
+ bg: '#fff',
128
+ fontWeight: '700',
129
+ count: 5,
130
+ })
131
+ expect(result).toEqual([['title', 'hi']])
132
+ })
133
+
134
+ it('keeps multiple valid text entries', () => {
135
+ const result = nonColorTextEntries({ title: 'Hello', subtitle: 'World' })
136
+ expect(result).toEqual([
137
+ ['title', 'Hello'],
138
+ ['subtitle', 'World'],
139
+ ])
140
+ })
141
+
142
+ it('returns empty array when all entries are filtered out', () => {
143
+ const result = nonColorTextEntries({
144
+ fontStyle: 'italic',
145
+ bg: '#000',
146
+ count: 3,
147
+ })
148
+ expect(result).toEqual([])
149
+ })
150
+ })
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // TextFormattingToolbar component tests
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe('TextFormattingToolbar — bold button', () => {
157
+ it('7. bold button writes fontWeight "700" then "400" on second click', () => {
158
+ const updateFn = vi.fn(() => Promise.resolve())
159
+ const { rerender } = render(
160
+ <TextFormattingToolbar
161
+ slideId={SLIDE_ID}
162
+ element={makeOverlay({ title: 'Hello', fontWeight: '400' })}
163
+ updateOverlayProp={updateFn}
164
+ />,
165
+ )
166
+ const boldBtn = screen.getByRole('button', { name: /bold/i })
167
+ fireEvent.click(boldBtn)
168
+ expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontWeight', '700')
169
+
170
+ rerender(
171
+ <TextFormattingToolbar
172
+ slideId={SLIDE_ID}
173
+ element={makeOverlay({ title: 'Hello', fontWeight: '700' })}
174
+ updateOverlayProp={updateFn}
175
+ />,
176
+ )
177
+ fireEvent.click(screen.getByRole('button', { name: /bold/i }))
178
+ expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontWeight', '400')
179
+ })
180
+
181
+ it('8. bold aria-pressed is true for bold-weight values, false otherwise', () => {
182
+ const boldCases: Array<[unknown, boolean]> = [
183
+ ['700', true],
184
+ ['bold', true],
185
+ [700, true],
186
+ ['600', true],
187
+ ['400', false],
188
+ [400, false],
189
+ ['normal', false],
190
+ [undefined, false],
191
+ ['', false],
192
+ ]
193
+
194
+ for (const [fontWeight, expectedPressed] of boldCases) {
195
+ const props: Record<string, unknown> = { title: 'Hi' }
196
+ if (fontWeight !== undefined) props.fontWeight = fontWeight
197
+ const { unmount } = render(
198
+ <TextFormattingToolbar
199
+ slideId={SLIDE_ID}
200
+ element={makeOverlay(props)}
201
+ updateOverlayProp={vi.fn(() => Promise.resolve())}
202
+ />,
203
+ )
204
+ if (fontWeight === undefined) {
205
+ // fontWeight absent → bold button is hidden (prop not in contract)
206
+ expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument()
207
+ } else {
208
+ const boldBtn = screen.getByRole('button', { name: /bold/i })
209
+ expect(boldBtn.getAttribute('aria-pressed')).toBe(String(expectedPressed))
210
+ }
211
+ unmount()
212
+ }
213
+ })
214
+ })
215
+
216
+ describe('TextFormattingToolbar — italic button', () => {
217
+ it('9. italic button writes fontStyle "italic" then "normal" on second click', () => {
218
+ const updateFn = vi.fn(() => Promise.resolve())
219
+ const { rerender } = render(
220
+ <TextFormattingToolbar
221
+ slideId={SLIDE_ID}
222
+ element={makeOverlay({ title: 'Hello', fontStyle: 'normal' })}
223
+ updateOverlayProp={updateFn}
224
+ />,
225
+ )
226
+ fireEvent.click(screen.getByRole('button', { name: /italic/i }))
227
+ expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontStyle', 'italic')
228
+
229
+ rerender(
230
+ <TextFormattingToolbar
231
+ slideId={SLIDE_ID}
232
+ element={makeOverlay({ title: 'Hello', fontStyle: 'italic' })}
233
+ updateOverlayProp={updateFn}
234
+ />,
235
+ )
236
+ fireEvent.click(screen.getByRole('button', { name: /italic/i }))
237
+ expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontStyle', 'normal')
238
+ })
239
+ })
240
+
241
+ describe('TextFormattingToolbar — case button', () => {
242
+ it('10. case button cycles none → uppercase → lowercase → capitalize → none', () => {
243
+ const updateFn = vi.fn(() => Promise.resolve())
244
+
245
+ const { rerender } = render(
246
+ <TextFormattingToolbar
247
+ slideId={SLIDE_ID}
248
+ element={makeOverlay({ title: 'Hello', textTransform: 'none' })}
249
+ updateOverlayProp={updateFn}
250
+ />,
251
+ )
252
+ const getCaseBtn = () => screen.getByRole('button', { name: /text case/i })
253
+ fireEvent.click(getCaseBtn())
254
+ expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textTransform', 'uppercase')
255
+
256
+ const steps: Array<[string, string]> = [
257
+ ['none', 'uppercase'],
258
+ ['uppercase', 'lowercase'],
259
+ ['lowercase', 'capitalize'],
260
+ ['capitalize', 'none'],
261
+ ]
262
+ for (const [current, expected] of steps) {
263
+ rerender(
264
+ <TextFormattingToolbar
265
+ slideId={SLIDE_ID}
266
+ element={makeOverlay({ title: 'Hello', textTransform: current })}
267
+ updateOverlayProp={updateFn}
268
+ />,
269
+ )
270
+ fireEvent.click(getCaseBtn())
271
+ expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textTransform', expected)
272
+ }
273
+ })
274
+ })
275
+
276
+ describe('TextFormattingToolbar — alignment buttons', () => {
277
+ it('11. each alignment button writes the matching textAlign value', () => {
278
+ const updateFn = vi.fn(() => Promise.resolve())
279
+ renderToolbar({ title: 'Hello', textAlign: 'center' }, updateFn)
280
+
281
+ fireEvent.click(screen.getByRole('radio', { name: /align left/i }))
282
+ expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textAlign', 'left')
283
+
284
+ fireEvent.click(screen.getByRole('radio', { name: /align center/i }))
285
+ expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textAlign', 'center')
286
+
287
+ fireEvent.click(screen.getByRole('radio', { name: /align right/i }))
288
+ expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textAlign', 'right')
289
+ })
290
+
291
+ it('12. no alignment button is highlighted (aria-checked false) when textAlign is unset', () => {
292
+ // textAlign must be present for the radiogroup to render, but the value
293
+ // should not match any button. Use an empty string to make the prop present
294
+ // but unmatched so none of the three alignment buttons show as checked.
295
+ renderToolbar({ title: 'Hello', textAlign: '' }, vi.fn(() => Promise.resolve()))
296
+
297
+ const radios = screen.getAllByRole('radio')
298
+ expect(radios).toHaveLength(3)
299
+ for (const radio of radios) {
300
+ expect(radio.getAttribute('aria-checked')).toBe('false')
301
+ }
302
+ })
303
+ })
304
+
305
+ describe('TextFormattingToolbar — ARIA', () => {
306
+ it('15. toolbar has role="toolbar", alignment group has role="radiogroup", B/I have aria-pressed', () => {
307
+ renderToolbar(
308
+ { title: 'Hello', fontWeight: '400', fontStyle: 'normal', textAlign: 'center' },
309
+ vi.fn(() => Promise.resolve()),
310
+ )
311
+
312
+ expect(screen.getByRole('toolbar', { name: /text formatting/i })).toBeInTheDocument()
313
+ expect(screen.getByRole('radiogroup', { name: /text alignment/i })).toBeInTheDocument()
314
+
315
+ const boldBtn = screen.getByRole('button', { name: /bold/i })
316
+ expect(boldBtn).toHaveAttribute('aria-pressed')
317
+
318
+ const italicBtn = screen.getByRole('button', { name: /italic/i })
319
+ expect(italicBtn).toHaveAttribute('aria-pressed')
320
+ })
321
+ })
322
+
323
+ describe('TextFormattingToolbar — adaptive rendering by element prop schema', () => {
324
+ function fullCompliantOverlay(): OverlayElement {
325
+ return {
326
+ id: 'el-1', type: 'overlay', frame: 0, x: 0, y: 0, w: 200, h: 100, rotation: 0,
327
+ overlay: {
328
+ template: 'assets/static-text.jsx',
329
+ props: {
330
+ text: 'hi', fontSize: '64', fontFamily: 'Inter', fontWeight: '400',
331
+ fontStyle: 'normal', color: '#000', textAlign: 'center',
332
+ textTransform: 'none', bgColor: 'transparent',
333
+ },
334
+ },
335
+ };
336
+ }
337
+
338
+ it('renders all standard controls when every contract prop is present', () => {
339
+ render(
340
+ <TextFormattingToolbar
341
+ slideId={SLIDE_ID}
342
+ element={fullCompliantOverlay()}
343
+ updateOverlayProp={vi.fn(() => Promise.resolve())}
344
+ />,
345
+ );
346
+ expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
347
+ expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
348
+ expect(screen.getByRole('button', { name: /text case/i })).toBeInTheDocument();
349
+ expect(screen.getByLabelText(/text color/i)).toBeInTheDocument();
350
+ expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
351
+ expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
352
+ expect(screen.getByRole('radio', { name: /align left/i })).toBeInTheDocument();
353
+ });
354
+
355
+ it('hides each control whose target prop is absent from the element', () => {
356
+ const partial = makeOverlay({ text: 'hi', fontSize: '64' });
357
+ render(
358
+ <TextFormattingToolbar
359
+ slideId={SLIDE_ID}
360
+ element={partial}
361
+ updateOverlayProp={vi.fn(() => Promise.resolve())}
362
+ />,
363
+ );
364
+ expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
365
+ expect(screen.queryByRole('button', { name: /italic/i })).not.toBeInTheDocument();
366
+ expect(screen.queryByRole('button', { name: /text case/i })).not.toBeInTheDocument();
367
+ expect(screen.queryByLabelText(/text color/i)).not.toBeInTheDocument();
368
+ expect(screen.queryByLabelText(/font size/i)).toBeInTheDocument();
369
+ expect(screen.queryByLabelText(/font family/i)).not.toBeInTheDocument();
370
+ expect(screen.queryByRole('radio', { name: /align left/i })).not.toBeInTheDocument();
371
+ });
372
+
373
+ it('shows a fallback hint when no standard contract props are present', () => {
374
+ const noncompliant = makeOverlay({ headline: 'hi', headlineSize: '92', fg: '#000' });
375
+ render(
376
+ <TextFormattingToolbar
377
+ slideId={SLIDE_ID}
378
+ element={noncompliant}
379
+ updateOverlayProp={vi.fn(() => Promise.resolve())}
380
+ />,
381
+ );
382
+ expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
383
+ expect(screen.queryByLabelText(/font size/i)).not.toBeInTheDocument();
384
+ expect(screen.getByText(/property panel/i)).toBeInTheDocument();
385
+ });
386
+
387
+ it('shows the fallback hint when `text` is the only standard prop (no style controls available)', () => {
388
+ // Realistic legacy carousel: text key carries a color hex (mis-used by an
389
+ // older agent), no fontSize/color/weight/etc. on the element. Without the
390
+ // hint here, the operator sees an empty toolbar with no signal about why.
391
+ const textOnly = makeOverlay({ text: '#f8fafc', copy: 'real content', size: 58 });
392
+ render(
393
+ <TextFormattingToolbar
394
+ slideId={SLIDE_ID}
395
+ element={textOnly}
396
+ updateOverlayProp={vi.fn(() => Promise.resolve())}
397
+ />,
398
+ );
399
+ expect(screen.queryByLabelText(/font size/i)).not.toBeInTheDocument();
400
+ expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
401
+ expect(screen.getByText(/property panel/i)).toBeInTheDocument();
402
+ });
403
+
404
+ it('keeps the delete button regardless of contract compliance (when onDelete is supplied)', () => {
405
+ const noncompliant = makeOverlay({ headline: 'hi' });
406
+ render(
407
+ <TextFormattingToolbar
408
+ slideId={SLIDE_ID}
409
+ element={noncompliant}
410
+ updateOverlayProp={vi.fn(() => Promise.resolve())}
411
+ onDelete={vi.fn()}
412
+ />,
413
+ );
414
+ expect(screen.getByRole('button', { name: /delete text overlay/i })).toBeInTheDocument();
415
+ });
416
+ });
package/src/theme.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * editor-core / theme — the default theme for the editor running inside Montaj,
3
+ * plus the `applyTheme` helper that projects an `EditorTheme` onto an element as
4
+ * CSS custom properties.
5
+ *
6
+ * ── CSS variable naming convention ───────────────────────────────────────────
7
+ * Every token is written as a custom property prefixed `--editor-`:
8
+ * colors → --editor-bg, --editor-surface, --editor-accent,
9
+ * --editor-text, --editor-border, --editor-selection
10
+ * fonts → --editor-font-sans, --editor-font-serif, --editor-font-display
11
+ * radii → --editor-radius-{sm|md|lg}
12
+ * spacing → --editor-space-{n} (n = scale step)
13
+ *
14
+ * Optional tokens (serif/display fonts) are only written when present, so a
15
+ * host can detect their absence via an empty `getPropertyValue`. Editor styles
16
+ * reference these vars exclusively, which is what lets a host re-theme the same
17
+ * component without forking its CSS.
18
+ */
19
+ import type { EditorTheme } from './types'
20
+
21
+ /**
22
+ * Montaj's default editor theme. Values mirror Montaj's existing Tailwind /
23
+ * `index.css` palette, which is dark-first:
24
+ * background gray-900 (#111827, matches `surface.DEFAULT`)
25
+ * surface gray-800 (#1f2937, matches `surface.raised`)
26
+ * border gray-700 (#374151, matches `surface.overlay`)
27
+ * text gray-100 (#f3f4f6, matches `html.dark` text)
28
+ * accent indigo-500 (#6366f1) — Montaj's interactive accent
29
+ * selection indigo-400 (#818cf8) — element-selection highlight
30
+ * font Inter (the configured `fontFamily.sans`)
31
+ */
32
+ export const defaultMontajTheme: EditorTheme = {
33
+ colors: {
34
+ background: '#111827',
35
+ surface: '#1f2937',
36
+ accent: '#6366f1',
37
+ text: '#f3f4f6',
38
+ border: '#374151',
39
+ selection: '#818cf8',
40
+ },
41
+ fonts: {
42
+ sans: "'Inter', system-ui, -apple-system, sans-serif",
43
+ },
44
+ radii: {
45
+ sm: '0.25rem',
46
+ md: '0.5rem',
47
+ lg: '0.75rem',
48
+ },
49
+ spacing: {
50
+ 1: '0.25rem',
51
+ 2: '0.5rem',
52
+ 3: '0.75rem',
53
+ 4: '1rem',
54
+ 6: '1.5rem',
55
+ 8: '2rem',
56
+ },
57
+ }
58
+
59
+ /**
60
+ * Write `theme`'s tokens onto `el` as CSS custom properties following the
61
+ * convention documented at the top of this file. Idempotent — calling it again
62
+ * with a different theme overwrites the previously-set vars.
63
+ */
64
+ export function applyTheme(el: HTMLElement, theme: EditorTheme): void {
65
+ const { style } = el
66
+
67
+ // Colors
68
+ style.setProperty('--editor-bg', theme.colors.background)
69
+ style.setProperty('--editor-surface', theme.colors.surface)
70
+ style.setProperty('--editor-accent', theme.colors.accent)
71
+ style.setProperty('--editor-text', theme.colors.text)
72
+ style.setProperty('--editor-border', theme.colors.border)
73
+ style.setProperty('--editor-selection', theme.colors.selection)
74
+
75
+ // Fonts — sans is required; serif/display are written only when present.
76
+ style.setProperty('--editor-font-sans', theme.fonts.sans)
77
+ if (theme.fonts.serif !== undefined) {
78
+ style.setProperty('--editor-font-serif', theme.fonts.serif)
79
+ }
80
+ if (theme.fonts.display !== undefined) {
81
+ style.setProperty('--editor-font-display', theme.fonts.display)
82
+ }
83
+
84
+ // Radii
85
+ style.setProperty('--editor-radius-sm', theme.radii.sm)
86
+ style.setProperty('--editor-radius-md', theme.radii.md)
87
+ style.setProperty('--editor-radius-lg', theme.radii.lg)
88
+
89
+ // Spacing scale — one var per step.
90
+ for (const [step, value] of Object.entries(theme.spacing)) {
91
+ style.setProperty(`--editor-space-${step}`, value)
92
+ }
93
+ }