@dfosco/storyboard-react 3.6.1 → 3.8.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.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.6.1",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.6.1",
6
+ "@dfosco/storyboard-core": "3.8.0",
7
7
  "@dfosco/tiny-canvas": "^1.1.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
@@ -0,0 +1,205 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2
+ import CanvasPage from './CanvasPage.jsx'
3
+ import { getCanvasPrimerAttrs, getCanvasThemeVars } from './canvasTheme.js'
4
+ import { updateCanvas } from './canvasApi.js'
5
+
6
+ vi.mock('@dfosco/tiny-canvas', () => ({
7
+ Canvas: ({ children, onDragEnd }) => (
8
+ <div data-testid="tiny-canvas">
9
+ {children}
10
+ <button
11
+ data-testid="drag-widget"
12
+ onClick={() => onDragEnd?.('widget-1', { x: 111.4, y: 222.7 })}
13
+ >
14
+ drag widget
15
+ </button>
16
+ <button
17
+ data-testid="drag-source"
18
+ onClick={() => onDragEnd?.('jsx-PrimaryButtons', { x: 333.2, y: 444.8 })}
19
+ >
20
+ drag source
21
+ </button>
22
+ </div>
23
+ ),
24
+ }))
25
+
26
+ const mockCanvas = {
27
+ title: 'Bridge Test Canvas',
28
+ widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
29
+ sources: [{ export: 'PrimaryButtons', position: { x: 1, y: 2 } }],
30
+ centered: false,
31
+ dotted: false,
32
+ grid: false,
33
+ gridSize: 18,
34
+ colorMode: 'auto',
35
+ }
36
+
37
+ vi.mock('./useCanvas.js', () => ({
38
+ useCanvas: () => ({
39
+ canvas: mockCanvas,
40
+ jsxExports: {
41
+ PrimaryButtons: () => <div data-testid="jsx-widget-content">jsx widget</div>,
42
+ },
43
+ loading: false,
44
+ }),
45
+ }))
46
+
47
+ vi.mock('./widgets/index.js', () => ({
48
+ getWidgetComponent: () => function MockWidget() { return <div>mock widget</div> },
49
+ }))
50
+
51
+ vi.mock('./widgets/widgetProps.js', () => ({
52
+ schemas: {},
53
+ getDefaults: () => ({}),
54
+ }))
55
+
56
+ vi.mock('./canvasApi.js', () => ({
57
+ addWidget: vi.fn(),
58
+ updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
59
+ removeWidget: vi.fn(),
60
+ }))
61
+
62
+ describe('CanvasPage canvas bridge', () => {
63
+ beforeEach(() => {
64
+ delete window.__storyboardCanvasBridgeState
65
+ vi.clearAllMocks()
66
+ })
67
+
68
+ it('publishes bridge state and responds to status requests', () => {
69
+ const mountedHandler = vi.fn()
70
+ const statusHandler = vi.fn()
71
+ document.addEventListener('storyboard:canvas:mounted', mountedHandler)
72
+ document.addEventListener('storyboard:canvas:status', statusHandler)
73
+
74
+ const { unmount } = render(<CanvasPage name="design-overview" />)
75
+
76
+ expect(window.__storyboardCanvasBridgeState).toEqual({
77
+ active: true,
78
+ name: 'design-overview',
79
+ zoom: 100,
80
+ })
81
+ expect(mountedHandler).toHaveBeenCalled()
82
+
83
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:status-request'))
84
+ expect(statusHandler).toHaveBeenCalled()
85
+ expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
86
+ active: true,
87
+ name: 'design-overview',
88
+ zoom: 100,
89
+ })
90
+
91
+ unmount()
92
+
93
+ document.removeEventListener('storyboard:canvas:mounted', mountedHandler)
94
+ document.removeEventListener('storyboard:canvas:status', statusHandler)
95
+ })
96
+
97
+ it('marks bridge inactive on unmount', () => {
98
+ const unmountedHandler = vi.fn()
99
+ document.addEventListener('storyboard:canvas:unmounted', unmountedHandler)
100
+
101
+ const { unmount } = render(<CanvasPage name="design-overview" />)
102
+ unmount()
103
+
104
+ expect(unmountedHandler).toHaveBeenCalled()
105
+ expect(window.__storyboardCanvasBridgeState).toEqual({
106
+ active: false,
107
+ name: '',
108
+ zoom: 100,
109
+ })
110
+
111
+ document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
112
+ })
113
+
114
+ it('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
115
+ render(<CanvasPage name="design-overview" />)
116
+
117
+ fireEvent.click(screen.getByTestId('drag-widget'))
118
+ await waitFor(() => {
119
+ expect(updateCanvas).toHaveBeenCalledWith(
120
+ 'design-overview',
121
+ expect.objectContaining({
122
+ widgets: expect.arrayContaining([
123
+ expect.objectContaining({
124
+ id: 'widget-1',
125
+ position: { x: 111, y: 223 },
126
+ }),
127
+ ]),
128
+ })
129
+ )
130
+ })
131
+
132
+ fireEvent.click(screen.getByTestId('drag-source'))
133
+ await waitFor(() => {
134
+ expect(updateCanvas).toHaveBeenCalledWith(
135
+ 'design-overview',
136
+ expect.objectContaining({
137
+ sources: expect.arrayContaining([
138
+ expect.objectContaining({
139
+ export: 'PrimaryButtons',
140
+ position: { x: 333, y: 445 },
141
+ }),
142
+ ]),
143
+ })
144
+ )
145
+ })
146
+ })
147
+ })
148
+
149
+ describe('getCanvasThemeVars', () => {
150
+ it('returns a distinct dark-dimmed background token', () => {
151
+ expect(getCanvasThemeVars('light')['--sb--canvas-bg']).toBe('#f6f8fa')
152
+ expect(getCanvasThemeVars('light')['--tc-bg-muted']).toBe('#f6f8fa')
153
+ expect(getCanvasThemeVars('dark')['--sb--canvas-bg']).toBe('#161b22')
154
+ expect(getCanvasThemeVars('dark')['--bgColor-muted']).toBe('#161b22')
155
+ expect(getCanvasThemeVars('dark')['--tc-bg-muted']).toBe('#161b22')
156
+ expect(getCanvasThemeVars('dark_dimmed')['--sb--canvas-bg']).toBe('#22272e')
157
+ expect(getCanvasThemeVars('dark_dimmed')['--bgColor-muted']).toBe('#22272e')
158
+ expect(getCanvasThemeVars('dark_dimmed')['--tc-bg-muted']).toBe('#22272e')
159
+ expect(getCanvasThemeVars('dark_dimmed')['--tc-dot-color']).toBe('rgba(205, 217, 229, 0.22)')
160
+ expect(getCanvasThemeVars('dark_dimmed')['--overlay-backdrop-bgColor']).toBe('rgba(205, 217, 229, 0.22)')
161
+ })
162
+ })
163
+
164
+ describe('getCanvasPrimerAttrs', () => {
165
+ it('maps canvas theme to local Primer mode attrs', () => {
166
+ expect(getCanvasPrimerAttrs('light')).toEqual({
167
+ 'data-color-mode': 'light',
168
+ 'data-dark-theme': 'dark',
169
+ 'data-light-theme': 'light',
170
+ })
171
+ expect(getCanvasPrimerAttrs('dark')).toEqual({
172
+ 'data-color-mode': 'dark',
173
+ 'data-dark-theme': 'dark',
174
+ 'data-light-theme': 'light',
175
+ })
176
+ expect(getCanvasPrimerAttrs('dark_dimmed')).toEqual({
177
+ 'data-color-mode': 'dark',
178
+ 'data-dark-theme': 'dark_dimmed',
179
+ 'data-light-theme': 'light',
180
+ })
181
+ })
182
+ })
183
+
184
+ describe('canvas target fallback', () => {
185
+ it('stays light when canvas target is unchecked even if stale canvas attribute is dark', () => {
186
+ localStorage.setItem('sb-theme-sync', JSON.stringify({
187
+ prototype: true,
188
+ toolbar: true,
189
+ codeBoxes: true,
190
+ canvas: false,
191
+ }))
192
+ localStorage.setItem('sb-color-scheme', 'dark')
193
+ document.documentElement.setAttribute('data-sb-canvas-theme', 'dark')
194
+
195
+ render(<CanvasPage name="design-overview" />)
196
+
197
+ const scroll = document.querySelector('[data-storyboard-canvas-scroll]')
198
+ const jsxWidget = document.getElementById('jsx-PrimaryButtons')
199
+ expect(scroll?.style.getPropertyValue('--sb--canvas-bg')).toBe('#f6f8fa')
200
+ expect(scroll?.style.getPropertyValue('--tc-bg-muted')).toBe('#f6f8fa')
201
+ expect(scroll?.getAttribute('data-color-mode')).toBe('light')
202
+ expect(jsxWidget?.getAttribute('data-color-mode')).toBe('light')
203
+ expect(jsxWidget?.style.getPropertyValue('--bgColor-default')).toBe('#ffffff')
204
+ })
205
+ })
@@ -2,6 +2,8 @@ import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { Canvas } from '@dfosco/tiny-canvas'
3
3
  import '@dfosco/tiny-canvas/style.css'
4
4
  import { useCanvas } from './useCanvas.js'
5
+ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
+ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
5
7
  import { getWidgetComponent } from './widgets/index.js'
6
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
7
9
  import ComponentWidget from './widgets/ComponentWidget.jsx'
@@ -11,6 +13,32 @@ import styles from './CanvasPage.module.css'
11
13
  const ZOOM_MIN = 25
12
14
  const ZOOM_MAX = 200
13
15
 
16
+ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
17
+
18
+ function getToolbarColorMode(theme) {
19
+ return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
20
+ }
21
+
22
+ function resolveCanvasThemeFromStorage() {
23
+ if (typeof localStorage === 'undefined') return 'light'
24
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
25
+ try {
26
+ const rawSync = localStorage.getItem('sb-theme-sync')
27
+ if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
28
+ } catch {
29
+ // Ignore malformed sync settings
30
+ }
31
+
32
+ if (!sync.canvas) return 'light'
33
+
34
+ const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
35
+ if (attrTheme) return attrTheme
36
+
37
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
38
+ if (stored !== 'system') return stored
39
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
40
+ }
41
+
14
42
  /**
15
43
  * Debounce helper — returns a function that delays invocation.
16
44
  */
@@ -22,21 +50,6 @@ function debounce(fn, ms) {
22
50
  }
23
51
  }
24
52
 
25
- /**
26
- * Save a drag position to localStorage so tiny-canvas picks it up on render.
27
- */
28
- function saveWidgetPosition(widgetId, x, y) {
29
- try {
30
- const queue = JSON.parse(localStorage.getItem('tiny-canvas-queue')) || []
31
- const now = new Date().toISOString().replace(/[:.]/g, '-')
32
- const entry = { id: widgetId, x, y, time: now }
33
- const idx = queue.findIndex((item) => item.id === widgetId)
34
- if (idx >= 0) queue[idx] = entry
35
- else queue.push(entry)
36
- localStorage.setItem('tiny-canvas-queue', JSON.stringify(queue))
37
- } catch { /* localStorage unavailable */ }
38
- }
39
-
40
53
  /**
41
54
  * Get viewport-center coordinates for placing a new widget.
42
55
  */
@@ -47,6 +60,10 @@ function getViewportCenter() {
47
60
  }
48
61
  }
49
62
 
63
+ function roundPosition(value) {
64
+ return Math.round(value)
65
+ }
66
+
50
67
  /** Renders a single JSON-defined widget by type lookup. */
51
68
  function WidgetRenderer({ widget, onUpdate }) {
52
69
  const Component = getWidgetComponent(widget.type)
@@ -75,13 +92,17 @@ export default function CanvasPage({ name }) {
75
92
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
76
93
  const [selectedWidgetId, setSelectedWidgetId] = useState(null)
77
94
  const [zoom, setZoom] = useState(100)
95
+ const zoomRef = useRef(100)
78
96
  const scrollRef = useRef(null)
79
97
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
80
98
  const titleInputRef = useRef(null)
99
+ const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
100
+ const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
81
101
 
82
102
  if (canvas !== trackedCanvas) {
83
103
  setTrackedCanvas(canvas)
84
104
  setLocalWidgets(canvas?.widgets ?? null)
105
+ setLocalSources(canvas?.sources ?? [])
85
106
  setCanvasTitle(canvas?.title || name)
86
107
  }
87
108
 
@@ -133,15 +154,61 @@ export default function CanvasPage({ name }) {
133
154
  )
134
155
  }, [name])
135
156
 
136
- // Signal canvas mount/unmount to CoreUIBar (include zoom state)
157
+ const handleItemDragEnd = useCallback((dragId, position) => {
158
+ if (!dragId || !position) return
159
+ const rounded = { x: roundPosition(position.x), y: roundPosition(position.y) }
160
+
161
+ if (dragId.startsWith('jsx-')) {
162
+ const sourceExport = dragId.replace(/^jsx-/, '')
163
+ setLocalSources((prev) => {
164
+ const current = Array.isArray(prev) ? prev : []
165
+ const next = current.some((s) => s?.export === sourceExport)
166
+ ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
167
+ : [...current, { export: sourceExport, position: rounded }]
168
+ updateCanvas(name, { sources: next }).catch((err) =>
169
+ console.error('[canvas] Failed to save source position:', err)
170
+ )
171
+ return next
172
+ })
173
+ return
174
+ }
175
+
176
+ setLocalWidgets((prev) => {
177
+ if (!prev) return prev
178
+ const next = prev.map((w) =>
179
+ w.id === dragId ? { ...w, position: rounded } : w
180
+ )
181
+ updateCanvas(name, { widgets: next }).catch((err) =>
182
+ console.error('[canvas] Failed to save widget position:', err)
183
+ )
184
+ return next
185
+ })
186
+ }, [name])
187
+
137
188
  useEffect(() => {
189
+ zoomRef.current = zoom
190
+ }, [zoom])
191
+
192
+ // Signal canvas mount/unmount to CoreUIBar
193
+ useEffect(() => {
194
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
138
195
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
139
- detail: { name, zoom }
196
+ detail: { name, zoom: zoomRef.current }
140
197
  }))
198
+
199
+ function handleStatusRequest() {
200
+ const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, name, zoom: zoomRef.current }
201
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
202
+ }
203
+
204
+ document.addEventListener('storyboard:canvas:status-request', handleStatusRequest)
205
+
141
206
  return () => {
207
+ document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
208
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: false, name: '', zoom: 100 }
142
209
  document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
143
210
  }
144
- }, [name, zoom])
211
+ }, [name])
145
212
 
146
213
  // Add a widget by type — used by CanvasControls and CoreUIBar event
147
214
  const addWidget = useCallback(async (type) => {
@@ -154,7 +221,6 @@ export default function CanvasPage({ name }) {
154
221
  position: pos,
155
222
  })
156
223
  if (result.success && result.widget) {
157
- saveWidgetPosition(result.widget.id, pos.x, pos.y)
158
224
  setLocalWidgets((prev) => [...(prev || []), result.widget])
159
225
  }
160
226
  } catch (err) {
@@ -183,14 +249,36 @@ export default function CanvasPage({ name }) {
183
249
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
184
250
  }, [])
185
251
 
252
+ // Canvas background should follow toolbar theme target.
253
+ useEffect(() => {
254
+ function readMode() {
255
+ setCanvasTheme(resolveCanvasThemeFromStorage())
256
+ }
257
+
258
+ readMode()
259
+ document.addEventListener('storyboard:theme:changed', readMode)
260
+ return () => document.removeEventListener('storyboard:theme:changed', readMode)
261
+ }, [])
262
+
186
263
  // Broadcast zoom level to CoreUIBar whenever it changes
187
264
  useEffect(() => {
265
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom }
188
266
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
189
267
  detail: { zoom }
190
268
  }))
191
- }, [zoom])
269
+ }, [name, zoom])
192
270
 
193
271
  // Delete selected widget on Delete/Backspace key
272
+ useEffect(() => {
273
+ function handleSelectStart(e) {
274
+ if (shouldPreventCanvasTextSelection(e.target)) {
275
+ e.preventDefault()
276
+ }
277
+ }
278
+ document.addEventListener('selectstart', handleSelectStart)
279
+ return () => document.removeEventListener('selectstart', handleSelectStart)
280
+ }, [])
281
+
194
282
  useEffect(() => {
195
283
  function handleKeyDown(e) {
196
284
  if (!selectedWidgetId) return
@@ -246,7 +334,6 @@ export default function CanvasPage({ name }) {
246
334
  position: pos,
247
335
  })
248
336
  if (result.success && result.widget) {
249
- saveWidgetPosition(result.widget.id, pos.x, pos.y)
250
337
  setLocalWidgets((prev) => [...(prev || []), result.widget])
251
338
  }
252
339
  } catch (err) {
@@ -355,17 +442,36 @@ export default function CanvasPage({ name }) {
355
442
  dotted: canvas.dotted ?? false,
356
443
  grid: canvas.grid ?? false,
357
444
  gridSize: canvas.gridSize ?? 18,
358
- colorMode: canvas.colorMode ?? 'auto',
445
+ colorMode: canvas.colorMode === 'auto'
446
+ ? getToolbarColorMode(canvasTheme)
447
+ : (canvas.colorMode ?? 'auto'),
359
448
  }
360
449
 
450
+ const canvasThemeVars = getCanvasThemeVars(canvasTheme)
451
+ const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
452
+
361
453
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
362
454
  const allChildren = []
363
455
 
456
+ const sourcePositionByExport = Object.fromEntries(
457
+ (localSources || [])
458
+ .filter((source) => source?.export)
459
+ .map((source) => [source.export, source.position || { x: 0, y: 0 }])
460
+ )
461
+
364
462
  // 1. JSX-sourced component widgets
365
463
  if (jsxExports) {
366
464
  for (const [exportName, Component] of Object.entries(jsxExports)) {
465
+ const sourcePosition = sourcePositionByExport[exportName] || { x: 0, y: 0 }
367
466
  allChildren.push(
368
- <div key={`jsx-${exportName}`} id={`jsx-${exportName}`}>
467
+ <div
468
+ key={`jsx-${exportName}`}
469
+ id={`jsx-${exportName}`}
470
+ data-tc-x={sourcePosition.x}
471
+ data-tc-y={sourcePosition.y}
472
+ {...canvasPrimerAttrs}
473
+ style={canvasThemeVars}
474
+ >
369
475
  <ComponentWidget component={Component} />
370
476
  </div>
371
477
  )
@@ -378,6 +484,10 @@ export default function CanvasPage({ name }) {
378
484
  <div
379
485
  key={widget.id}
380
486
  id={widget.id}
487
+ data-tc-x={widget?.position?.x ?? 0}
488
+ data-tc-y={widget?.position?.y ?? 0}
489
+ {...canvasPrimerAttrs}
490
+ style={canvasThemeVars}
381
491
  onClick={(e) => {
382
492
  e.stopPropagation()
383
493
  setSelectedWidgetId(widget.id)
@@ -411,12 +521,20 @@ export default function CanvasPage({ name }) {
411
521
  </div>
412
522
  <div
413
523
  ref={scrollRef}
524
+ data-storyboard-canvas-scroll
525
+ data-sb-canvas-theme={canvasTheme}
526
+ {...canvasPrimerAttrs}
414
527
  className={styles.canvasScroll}
415
- style={spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : undefined}
528
+ style={{
529
+ ...canvasThemeVars,
530
+ ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
531
+ }}
416
532
  onClick={() => setSelectedWidgetId(null)}
417
533
  onMouseDown={handlePanStart}
418
534
  >
419
535
  <div
536
+ data-storyboard-canvas-zoom
537
+ data-sb-canvas-theme={canvasTheme}
420
538
  className={styles.canvasZoom}
421
539
  style={{
422
540
  transform: `scale(${scale})`,
@@ -425,7 +543,7 @@ export default function CanvasPage({ name }) {
425
543
  height: `${Math.max(10000, 100 / scale)}vh`,
426
544
  }}
427
545
  >
428
- <Canvas {...canvasProps}>
546
+ <Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
429
547
  {allChildren}
430
548
  </Canvas>
431
549
  </div>
@@ -18,12 +18,12 @@
18
18
  width: 100vw;
19
19
  height: 100vh;
20
20
  overflow: auto;
21
- background-color: var(--bgColor-muted, #f6f8fa);
21
+ background-color: var(--sb--canvas-bg, var(--bgColor-muted, #f6f8fa));
22
22
  }
23
23
 
24
24
  @media (prefers-color-scheme: dark) {
25
25
  .canvasScroll {
26
- background-color: var(--bgColor-muted, #161b22);
26
+ background-color: var(--sb--canvas-bg, var(--bgColor-muted, #161b22));
27
27
  }
28
28
  }
29
29
 
@@ -0,0 +1,74 @@
1
+ export function getCanvasPrimerAttrs(theme) {
2
+ if (String(theme || 'light') === 'dark_dimmed') {
3
+ return {
4
+ 'data-color-mode': 'dark',
5
+ 'data-dark-theme': 'dark_dimmed',
6
+ 'data-light-theme': 'light',
7
+ }
8
+ }
9
+ if (String(theme || 'light').startsWith('dark')) {
10
+ return {
11
+ 'data-color-mode': 'dark',
12
+ 'data-dark-theme': 'dark',
13
+ 'data-light-theme': 'light',
14
+ }
15
+ }
16
+ return {
17
+ 'data-color-mode': 'light',
18
+ 'data-dark-theme': 'dark',
19
+ 'data-light-theme': 'light',
20
+ }
21
+ }
22
+
23
+ export function getCanvasThemeVars(theme) {
24
+ const value = String(theme || 'light')
25
+ if (value === 'dark_dimmed') {
26
+ return {
27
+ '--sb--canvas-bg': '#22272e',
28
+ '--bgColor-default': '#22272e',
29
+ '--bgColor-muted': '#22272e',
30
+ '--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
31
+ '--bgColor-accent-emphasis': '#316dca',
32
+ '--tc-bg-muted': '#22272e',
33
+ '--tc-dot-color': 'rgba(205, 217, 229, 0.22)',
34
+ '--overlay-backdrop-bgColor': 'rgba(205, 217, 229, 0.22)',
35
+ '--fgColor-muted': '#768390',
36
+ '--fgColor-default': '#adbac7',
37
+ '--fgColor-onEmphasis': '#ffffff',
38
+ '--borderColor-default': '#444c56',
39
+ '--borderColor-muted': '#545d68',
40
+ }
41
+ }
42
+ if (value.startsWith('dark')) {
43
+ return {
44
+ '--sb--canvas-bg': '#161b22',
45
+ '--bgColor-default': '#161b22',
46
+ '--bgColor-muted': '#161b22',
47
+ '--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
48
+ '--bgColor-accent-emphasis': '#2f81f7',
49
+ '--tc-bg-muted': '#161b22',
50
+ '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
51
+ '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
52
+ '--fgColor-muted': '#8b949e',
53
+ '--fgColor-default': '#e6edf3',
54
+ '--fgColor-onEmphasis': '#ffffff',
55
+ '--borderColor-default': '#30363d',
56
+ '--borderColor-muted': '#30363d',
57
+ }
58
+ }
59
+ return {
60
+ '--sb--canvas-bg': '#f6f8fa',
61
+ '--bgColor-default': '#ffffff',
62
+ '--tc-bg-muted': '#f6f8fa',
63
+ '--tc-dot-color': 'rgba(0, 0, 0, 0.08)',
64
+ '--overlay-backdrop-bgColor': 'rgba(0, 0, 0, 0.08)',
65
+ '--bgColor-muted': '#f6f8fa',
66
+ '--bgColor-neutral-muted': '#eaeef2',
67
+ '--bgColor-accent-emphasis': '#2f81f7',
68
+ '--fgColor-muted': '#656d76',
69
+ '--fgColor-default': '#1f2328',
70
+ '--fgColor-onEmphasis': '#ffffff',
71
+ '--borderColor-default': '#d1d9e0',
72
+ '--borderColor-muted': '#d8dee4',
73
+ }
74
+ }
@@ -0,0 +1,10 @@
1
+ const TEXT_SELECTION_EDITING_SELECTOR = 'textarea, [contenteditable="true"], [contenteditable=""], [data-canvas-allow-text-selection]'
2
+
3
+ /**
4
+ * Returns true when canvas mouse interactions should suppress browser text selection.
5
+ */
6
+ export function shouldPreventCanvasTextSelection(target) {
7
+ if (!(target instanceof Element)) return true
8
+ return !target.closest(TEXT_SELECTION_EDITING_SELECTOR)
9
+ }
10
+
@@ -0,0 +1,26 @@
1
+ import { shouldPreventCanvasTextSelection } from './textSelection.js'
2
+
3
+ describe('shouldPreventCanvasTextSelection', () => {
4
+ it('prevents selection for regular canvas elements', () => {
5
+ const container = document.createElement('div')
6
+ const regular = document.createElement('p')
7
+ container.appendChild(regular)
8
+
9
+ expect(shouldPreventCanvasTextSelection(regular)).toBe(true)
10
+ })
11
+
12
+ it('allows selection for textarea editing', () => {
13
+ const textarea = document.createElement('textarea')
14
+ expect(shouldPreventCanvasTextSelection(textarea)).toBe(false)
15
+ })
16
+
17
+ it('allows selection for explicit editable markers', () => {
18
+ const wrapper = document.createElement('div')
19
+ wrapper.setAttribute('data-canvas-allow-text-selection', '')
20
+ const child = document.createElement('span')
21
+ wrapper.appendChild(child)
22
+
23
+ expect(shouldPreventCanvasTextSelection(child)).toBe(false)
24
+ })
25
+ })
26
+
@@ -70,5 +70,25 @@ export function useCanvas(name) {
70
70
  })
71
71
  }, [canvas?._jsxModule])
72
72
 
73
+ // In dev, react to file mutations from the data plugin without reloading
74
+ // the current page. This keeps canvas editing state and route stable.
75
+ useEffect(() => {
76
+ if (!import.meta.hot || !buildTimeCanvas) return
77
+
78
+ const handleCanvasFileChanged = ({ data }) => {
79
+ if (!data || data.name !== name) return
80
+ fetchCanvasFromServer(name).then((fresh) => {
81
+ if (fresh) {
82
+ setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
83
+ }
84
+ })
85
+ }
86
+
87
+ import.meta.hot.on('storyboard:canvas-file-changed', handleCanvasFileChanged)
88
+ return () => {
89
+ import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
90
+ }
91
+ }, [name, buildTimeCanvas])
92
+
73
93
  return { canvas, jsxExports, loading }
74
94
  }
@@ -62,6 +62,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
62
62
  <textarea
63
63
  ref={textareaRef}
64
64
  className={styles.editor}
65
+ data-canvas-allow-text-selection
65
66
  style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
66
67
  value={content}
67
68
  onChange={handleContentChange}
@@ -1,6 +1,11 @@
1
1
  .block {
2
2
  min-height: 80px;
3
- background: var(--bgColor-default, #ffffff);
3
+ --sb--markdown-bg: var(--bgColor-default, #ffffff);
4
+ --sb--markdown-fg: var(--fgColor-default, #1f2328);
5
+ --sb--markdown-muted: var(--fgColor-muted, #656d76);
6
+ --sb--markdown-accent: var(--bgColor-accent-emphasis, #2f81f7);
7
+ background: var(--sb--markdown-bg);
8
+ color: var(--sb--markdown-fg);
4
9
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
5
10
  }
6
11
 
@@ -8,11 +13,15 @@
8
13
  padding: 16px 20px;
9
14
  font-size: 14px;
10
15
  line-height: 1.6;
11
- color: var(--fgColor-default, #1f2328);
16
+ color: var(--sb--markdown-fg);
12
17
  cursor: text;
13
18
  min-height: 60px;
14
19
  }
15
20
 
21
+ .preview :global(*) {
22
+ color: inherit;
23
+ }
24
+
16
25
  .preview h1 {
17
26
  font-size: 20px;
18
27
  font-weight: 700;
@@ -56,7 +65,7 @@
56
65
  }
57
66
 
58
67
  .preview :global(.placeholder) {
59
- color: var(--fgColor-muted, #656d76);
68
+ color: var(--sb--markdown-muted);
60
69
  font-style: italic;
61
70
  }
62
71
 
@@ -69,10 +78,10 @@
69
78
  padding: 16px 20px;
70
79
  border: none;
71
80
  outline: none;
72
- background: var(--bgColor-default, #ffffff);
81
+ background: var(--sb--markdown-bg);
73
82
  font-family: ui-monospace, SFMono-Regular, monospace;
74
83
  font-size: 13px;
75
84
  line-height: 1.5;
76
- color: var(--fgColor-default, #1f2328);
85
+ color: var(--sb--markdown-fg);
77
86
  resize: none;
78
87
  }
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
2
  import { buildPrototypeIndex } from '@dfosco/storyboard-core'
3
3
  import WidgetWrapper from './WidgetWrapper.jsx'
4
4
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
5
+ import { getEmbedChromeVars } from './embedTheme.js'
5
6
  import styles from './PrototypeEmbed.module.css'
6
7
 
7
8
  function formatName(name) {
@@ -10,6 +11,23 @@ function formatName(name) {
10
11
  .replace(/\b\w/g, (c) => c.toUpperCase())
11
12
  }
12
13
 
14
+ function resolveCanvasThemeFromStorage() {
15
+ if (typeof localStorage === 'undefined') return 'light'
16
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
17
+ try {
18
+ const rawSync = localStorage.getItem('sb-theme-sync')
19
+ if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
20
+ } catch {
21
+ // Ignore malformed sync settings
22
+ }
23
+ if (!sync.canvas) return 'light'
24
+ const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
25
+ if (attrTheme) return attrTheme
26
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
27
+ if (stored !== 'system') return stored
28
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
29
+ }
30
+
13
31
  export default function PrototypeEmbed({ props, onUpdate }) {
14
32
  const src = readProp(props, 'src', prototypeEmbedSchema)
15
33
  const width = readProp(props, 'width', prototypeEmbedSchema)
@@ -19,17 +37,21 @@ export default function PrototypeEmbed({ props, onUpdate }) {
19
37
 
20
38
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
21
39
  const rawSrc = src ? `${basePath}${src}` : ''
22
- const iframeSrc = rawSrc ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed` : ''
23
40
 
24
41
  const scale = zoom / 100
25
42
 
26
43
  const [editing, setEditing] = useState(false)
27
44
  const [interactive, setInteractive] = useState(false)
28
45
  const [filter, setFilter] = useState('')
46
+ const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
29
47
  const inputRef = useRef(null)
30
48
  const filterRef = useRef(null)
31
49
  const embedRef = useRef(null)
32
50
 
51
+ const iframeSrc = rawSrc
52
+ ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}`
53
+ : ''
54
+
33
55
  // Build prototype index for the picker
34
56
  const prototypeIndex = useMemo(() => {
35
57
  try {
@@ -132,6 +154,17 @@ export default function PrototypeEmbed({ props, onUpdate }) {
132
154
  return () => document.removeEventListener('pointerdown', handlePointerDown)
133
155
  }, [interactive])
134
156
 
157
+ useEffect(() => {
158
+ function readToolbarTheme() {
159
+ setCanvasTheme(resolveCanvasThemeFromStorage())
160
+ }
161
+ readToolbarTheme()
162
+ document.addEventListener('storyboard:theme:changed', readToolbarTheme)
163
+ return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
164
+ }, [])
165
+
166
+ const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
167
+
135
168
  const enterInteractive = useCallback(() => setInteractive(true), [])
136
169
 
137
170
  function handlePickRoute(route) {
@@ -158,7 +191,7 @@ export default function PrototypeEmbed({ props, onUpdate }) {
158
191
  <div
159
192
  ref={embedRef}
160
193
  className={styles.embed}
161
- style={{ width, height }}
194
+ style={{ width, height, ...chromeVars }}
162
195
  >
163
196
  {editing ? (
164
197
  <div
@@ -52,10 +52,9 @@
52
52
  align-items: center;
53
53
  justify-content: center;
54
54
  border-radius: 6px;
55
- background: rgba(255, 255, 255, 0.92);
56
- backdrop-filter: blur(12px);
57
- -webkit-backdrop-filter: blur(12px);
58
- border: 1px solid rgba(0, 0, 0, 0.12);
55
+ background: var(--bgColor-default, rgba(255, 255, 255, 0.92));
56
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.12));
57
+ color: var(--fgColor-default, #1f2328);
59
58
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
60
59
  font-size: 14px;
61
60
  opacity: 0;
@@ -68,18 +67,7 @@
68
67
  }
69
68
 
70
69
  .editBtn:hover {
71
- background: rgba(255, 255, 255, 0.98);
72
- }
73
-
74
- @media (prefers-color-scheme: dark) {
75
- .editBtn {
76
- background: rgba(22, 27, 34, 0.88);
77
- border-color: rgba(255, 255, 255, 0.1);
78
- }
79
-
80
- .editBtn:hover {
81
- background: rgba(30, 37, 46, 0.95);
82
- }
70
+ background: var(--bgColor-muted, rgba(255, 255, 255, 0.98));
83
71
  }
84
72
 
85
73
  .urlForm {
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getEmbedChromeVars } from './embedTheme.js'
3
+
4
+ describe('getEmbedChromeVars', () => {
5
+ it('follows toolbar theme variants for embed edit chrome', () => {
6
+ expect(getEmbedChromeVars('light')['--bgColor-default']).toBe('#ffffff')
7
+ expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#161b22')
8
+ expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#22272e')
9
+ })
10
+ })
@@ -53,6 +53,7 @@ export default function StickyNote({ props, onUpdate }) {
53
53
  <textarea
54
54
  ref={textareaRef}
55
55
  className={styles.textarea}
56
+ data-canvas-allow-text-selection
56
57
  value={text}
57
58
  onChange={handleTextChange}
58
59
  onBlur={() => setEditing(false)}
@@ -95,4 +96,3 @@ export default function StickyNote({ props, onUpdate }) {
95
96
  </div>
96
97
  )
97
98
  }
98
-
@@ -14,6 +14,12 @@
14
14
  position: relative;
15
15
  }
16
16
 
17
+ :global([data-sb-canvas-theme^='dark']) .sticky {
18
+ background: color-mix(in srgb, var(--sticky-bg) 30%, #0d1117 70%);
19
+ border-color: color-mix(in srgb, var(--sticky-bg) 55%, #f0f6fc 18%);
20
+ box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
21
+ }
22
+
17
23
  .text {
18
24
  padding: 16px 20px;
19
25
  margin: 0;
@@ -26,6 +32,10 @@
26
32
  min-height: 60px;
27
33
  }
28
34
 
35
+ :global([data-sb-canvas-theme^='dark']) .text {
36
+ color: color-mix(in srgb, var(--sticky-bg) 30%, #f0f6fc 70%);
37
+ }
38
+
29
39
  .textarea {
30
40
  position: absolute;
31
41
  top: 0;
@@ -47,6 +57,10 @@
47
57
  resize: none;
48
58
  }
49
59
 
60
+ :global([data-sb-canvas-theme^='dark']) .textarea {
61
+ color: color-mix(in srgb, var(--sticky-bg) 26%, #f0f6fc 74%);
62
+ }
63
+
50
64
  /* Color picker area — sits below the sticky */
51
65
 
52
66
  .pickerArea {
@@ -82,6 +96,13 @@
82
96
  z-index: 10;
83
97
  }
84
98
 
99
+ :global([data-sb-canvas-theme^='dark']) .pickerPopup {
100
+ background: var(--bgColor-muted, #161b22);
101
+ box-shadow:
102
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
103
+ 0 4px 12px rgba(0, 0, 0, 0.45);
104
+ }
105
+
85
106
  .pickerArea:hover .pickerDot {
86
107
  opacity: 0;
87
108
  }
@@ -0,0 +1,49 @@
1
+ export function getEmbedChromeVars(theme) {
2
+ const value = String(theme || 'light')
3
+ if (value === 'dark_dimmed') {
4
+ return {
5
+ '--bgColor-default': '#22272e',
6
+ '--bgColor-muted': '#2d333b',
7
+ '--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
8
+ '--fgColor-default': '#adbac7',
9
+ '--fgColor-muted': '#768390',
10
+ '--fgColor-onEmphasis': '#ffffff',
11
+ '--borderColor-default': '#444c56',
12
+ '--borderColor-muted': '#545d68',
13
+ '--bgColor-accent-emphasis': '#316dca',
14
+ '--trigger-bg': '#2d333b',
15
+ '--trigger-bg-hover': '#373e47',
16
+ '--trigger-border': '#444c56',
17
+ }
18
+ }
19
+ if (value.startsWith('dark')) {
20
+ return {
21
+ '--bgColor-default': '#161b22',
22
+ '--bgColor-muted': '#21262d',
23
+ '--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
24
+ '--fgColor-default': '#e6edf3',
25
+ '--fgColor-muted': '#8b949e',
26
+ '--fgColor-onEmphasis': '#ffffff',
27
+ '--borderColor-default': '#30363d',
28
+ '--borderColor-muted': '#30363d',
29
+ '--bgColor-accent-emphasis': '#2f81f7',
30
+ '--trigger-bg': '#21262d',
31
+ '--trigger-bg-hover': '#30363d',
32
+ '--trigger-border': '#30363d',
33
+ }
34
+ }
35
+ return {
36
+ '--bgColor-default': '#ffffff',
37
+ '--bgColor-muted': '#f6f8fa',
38
+ '--bgColor-neutral-muted': '#eaeef2',
39
+ '--fgColor-default': '#1f2328',
40
+ '--fgColor-muted': '#656d76',
41
+ '--fgColor-onEmphasis': '#ffffff',
42
+ '--borderColor-default': '#d0d7de',
43
+ '--borderColor-muted': '#d8dee4',
44
+ '--bgColor-accent-emphasis': '#2f81f7',
45
+ '--trigger-bg': '#f6f8fa',
46
+ '--trigger-bg-hover': '#eaeef2',
47
+ '--trigger-border': '#d0d7de',
48
+ }
49
+ }
package/src/context.jsx CHANGED
@@ -73,6 +73,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
73
73
  if (canvasName) return null
74
74
  const requested = sceneParam || flowName || sceneName
75
75
  if (requested) {
76
+ // Allow fully-scoped flow names from URLs/widgets without re-prefixing
77
+ // (e.g. "Proto/flow" should not become "Proto/Proto/flow").
78
+ if (requested.includes('/')) return requested
76
79
  return resolveFlowName(prototypeName, requested)
77
80
  }
78
81
  // 1. Page-specific flow (e.g., Example/Forms)
@@ -83,8 +86,14 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
83
86
  const protoFlow = resolveFlowName(prototypeName, prototypeName)
84
87
  if (flowExists(protoFlow)) return protoFlow
85
88
  }
86
- // 3. Global default
87
- return 'default'
89
+ // 3. Prototype-scoped default (e.g. Example/default)
90
+ if (prototypeName) {
91
+ const scopedDefault = resolveFlowName(prototypeName, 'default')
92
+ if (flowExists(scopedDefault)) return scopedDefault
93
+ }
94
+ // 4. Global default — or null if no flow exists at all
95
+ if (flowExists('default')) return 'default'
96
+ return null
88
97
  }, [canvasName, sceneParam, flowName, sceneName, prototypeName, pageFlow])
89
98
 
90
99
  // Auto-install body class sync (sb-key--value classes on <body>)
@@ -106,9 +115,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
106
115
  return () => cleanup?.()
107
116
  }, [])
108
117
 
109
- // Skip flow loading for canvas pages
118
+ // Skip flow loading for canvas pages and flow-less pages
110
119
  const { data, error } = useMemo(() => {
111
120
  if (canvasName) return { data: null, error: null }
121
+ if (!activeFlowName) return { data: {}, error: null }
112
122
  try {
113
123
  let flowData = loadFlow(activeFlowName)
114
124
 
@@ -573,13 +573,37 @@ export default function storyboardDataPlugin() {
573
573
  configureServer(server) {
574
574
  // Watch for data file changes in dev mode
575
575
  const watcher = server.watcher
576
+ if (!buildResult) buildResult = buildIndex(root)
577
+ const knownCanvasNames = new Set(Object.keys(buildResult.index.canvas || {}))
578
+ const pendingCanvasUnlinks = new Map()
579
+
580
+ const triggerFullReload = () => {
581
+ buildResult = null
582
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
583
+ if (mod) {
584
+ server.moduleGraph.invalidateModule(mod)
585
+ server.ws.send({ type: 'full-reload' })
586
+ }
587
+ }
576
588
 
577
589
  const invalidate = (filePath) => {
578
590
  const normalized = filePath.replace(/\\/g, '/')
579
591
  // Skip .canvas.jsonl content changes entirely — these are mutated
580
592
  // at runtime by the canvas server API. A full-reload would create
581
593
  // a feedback loop (save → file change → reload → lose editing state).
582
- if (/\.canvas\.jsonl$/.test(normalized)) return
594
+ // Instead, send a custom HMR event so the active canvas page can refetch
595
+ // file-backed data in place with no navigation or document reload.
596
+ if (/\.canvas\.jsonl$/.test(normalized)) {
597
+ const parsed = parseDataFile(filePath)
598
+ if (parsed?.suffix === 'canvas' && parsed?.name) {
599
+ server.ws.send({
600
+ type: 'custom',
601
+ event: 'storyboard:canvas-file-changed',
602
+ data: { name: parsed.name },
603
+ })
604
+ }
605
+ return
606
+ }
583
607
 
584
608
  // Invalidate when toolbar.config.json inside a prototype changes
585
609
  if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
@@ -605,17 +629,64 @@ export default function storyboardDataPlugin() {
605
629
  }
606
630
  }
607
631
 
608
- const invalidateOnAddRemove = (filePath) => {
632
+ const invalidateOnAddRemove = (filePath, eventType) => {
609
633
  const parsed = parseDataFile(filePath)
610
634
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
611
635
  if (!parsed && !inFolder) return
612
- // Canvas additions/removals DO need a reload (new routes)
613
- buildResult = null
614
- const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
615
- if (mod) {
616
- server.moduleGraph.invalidateModule(mod)
617
- server.ws.send({ type: 'full-reload' })
636
+
637
+ // Canvas writers/editors can emit unlink+add for an in-place save.
638
+ // Treat canvas add/unlink as runtime data updates and never full-reload
639
+ // from watcher events. Canvas pages sync from disk via custom WS events.
640
+ if (parsed?.suffix === 'canvas') {
641
+ const name = parsed.name
642
+ if (eventType === 'unlink') {
643
+ const timer = setTimeout(() => {
644
+ pendingCanvasUnlinks.delete(name)
645
+ knownCanvasNames.delete(name)
646
+ server.ws.send({
647
+ type: 'custom',
648
+ event: 'storyboard:canvas-file-changed',
649
+ data: { name },
650
+ })
651
+ }, 1500)
652
+ pendingCanvasUnlinks.set(name, timer)
653
+ return
654
+ }
655
+
656
+ if (eventType === 'add') {
657
+ const pending = pendingCanvasUnlinks.get(name)
658
+ if (pending) {
659
+ clearTimeout(pending)
660
+ pendingCanvasUnlinks.delete(name)
661
+ server.ws.send({
662
+ type: 'custom',
663
+ event: 'storyboard:canvas-file-changed',
664
+ data: { name },
665
+ })
666
+ return
667
+ }
668
+
669
+ if (knownCanvasNames.has(name)) {
670
+ server.ws.send({
671
+ type: 'custom',
672
+ event: 'storyboard:canvas-file-changed',
673
+ data: { name },
674
+ })
675
+ return
676
+ }
677
+
678
+ knownCanvasNames.add(name)
679
+ server.ws.send({
680
+ type: 'custom',
681
+ event: 'storyboard:canvas-file-changed',
682
+ data: { name },
683
+ })
684
+ return
685
+ }
618
686
  }
687
+
688
+ // Non-canvas additions/removals and folder changes update the route/data graph.
689
+ triggerFullReload()
619
690
  }
620
691
 
621
692
  // Watch storyboard.config.json for changes
@@ -632,14 +703,33 @@ export default function storyboardDataPlugin() {
632
703
  }
633
704
  }
634
705
 
635
- watcher.on('add', invalidateOnAddRemove)
636
- watcher.on('unlink', invalidateOnAddRemove)
706
+ watcher.on('add', (filePath) => invalidateOnAddRemove(filePath, 'add'))
707
+ watcher.on('unlink', (filePath) => invalidateOnAddRemove(filePath, 'unlink'))
637
708
  watcher.on('change', (filePath) => {
638
709
  invalidate(filePath)
639
710
  invalidateConfig(filePath)
640
711
  })
641
712
  },
642
713
 
714
+ handleHotUpdate(ctx) {
715
+ const normalized = ctx.file.replace(/\\/g, '/')
716
+ if (!/\.canvas\.jsonl$/.test(normalized)) return
717
+
718
+ const parsed = parseDataFile(ctx.file)
719
+ if (parsed?.suffix === 'canvas' && parsed?.name) {
720
+ ctx.server.ws.send({
721
+ type: 'custom',
722
+ event: 'storyboard:canvas-file-changed',
723
+ data: { name: parsed.name },
724
+ })
725
+ }
726
+
727
+ // Prevent Vite's default fallback behavior (full page reload) for
728
+ // non-module .canvas.jsonl edits. Canvas pages consume these updates
729
+ // through the custom WS event and in-page refetch.
730
+ return []
731
+ },
732
+
643
733
  // Rebuild index on each build start
644
734
  buildStart() {
645
735
  buildResult = null