@dfosco/storyboard-react 4.0.0-beta.10 → 4.0.0-beta.12

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,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.10",
3
+ "version": "4.0.0-beta.12",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.10",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.10",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.12",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.12",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -257,7 +257,7 @@ function ChromeWrappedWidget({
257
257
  readOnly,
258
258
  }) {
259
259
  const widgetRef = useRef(null)
260
- const features = getFeatures(widget.type)
260
+ const features = getFeatures(widget.type, { isLocalDev: !readOnly })
261
261
 
262
262
  const handleAction = useCallback((actionId) => {
263
263
  if (actionId === 'delete') {
@@ -1502,7 +1502,7 @@ export default function CanvasPage({ name }) {
1502
1502
  const allChildren = []
1503
1503
 
1504
1504
  // 1. Component widgets (from jsxExports or sources fallback)
1505
- const componentFeatures = getFeatures('component')
1505
+ const componentFeatures = getFeatures('component', { isLocalDev })
1506
1506
  for (const entry of componentEntries) {
1507
1507
  const { exportName, Component, sourceData } = entry
1508
1508
  const sourcePosition = sourceData.position || { x: 0, y: 0 }
@@ -12,6 +12,25 @@
12
12
  */
13
13
  import { createElement, Component as ReactComponent } from 'react'
14
14
  import { createRoot } from 'react-dom/client'
15
+ import { ThemeProvider, BaseStyles } from '@primer/react'
16
+
17
+ // ── Primer Primitives CSS (required for CSS variables) ──────────────
18
+ import '@primer/primitives/dist/css/base/size/size.css'
19
+ import '@primer/primitives/dist/css/base/typography/typography.css'
20
+ import '@primer/primitives/dist/css/base/motion/motion.css'
21
+ import '@primer/primitives/dist/css/functional/size/border.css'
22
+ import '@primer/primitives/dist/css/functional/size/breakpoints.css'
23
+ import '@primer/primitives/dist/css/functional/size/size-coarse.css'
24
+ import '@primer/primitives/dist/css/functional/size/size-fine.css'
25
+ import '@primer/primitives/dist/css/functional/size/size.css'
26
+ import '@primer/primitives/dist/css/functional/size/viewport.css'
27
+ import '@primer/primitives/dist/css/functional/typography/typography.css'
28
+ import '@primer/primitives/dist/css/functional/themes/light.css'
29
+ import '@primer/primitives/dist/css/functional/themes/light-colorblind.css'
30
+ import '@primer/primitives/dist/css/functional/themes/dark.css'
31
+ import '@primer/primitives/dist/css/functional/themes/dark-colorblind.css'
32
+ import '@primer/primitives/dist/css/functional/themes/dark-high-contrast.css'
33
+ import '@primer/primitives/dist/css/functional/themes/dark-dimmed.css'
15
34
 
16
35
  // ── Error Boundary ──────────────────────────────────────────────────
17
36
  class IsolateErrorBoundary extends ReactComponent {
@@ -62,6 +81,9 @@ const modulePath = params.get('module')
62
81
  const exportName = params.get('export')
63
82
  const theme = params.get('theme') || 'light'
64
83
 
84
+ // Map theme to Primer colorMode
85
+ const colorMode = theme.startsWith('dark') ? 'night' : 'day'
86
+
65
87
  // Apply theme to document for Primer / CSS-var inheritance
66
88
  document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
67
89
  document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
@@ -91,8 +113,12 @@ async function mount() {
91
113
  }
92
114
 
93
115
  root.render(
94
- createElement(IsolateErrorBoundary, { name: exportName },
95
- createElement(Component),
116
+ createElement(ThemeProvider, { colorMode },
117
+ createElement(BaseStyles, null,
118
+ createElement(IsolateErrorBoundary, { name: exportName },
119
+ createElement(Component),
120
+ ),
121
+ ),
96
122
  ),
97
123
  )
98
124
  } catch (err) {
@@ -3,6 +3,7 @@ import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
4
  import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
5
5
  import styles from './ComponentWidget.module.css'
6
+ import overlayStyles from './embedOverlay.module.css'
6
7
 
7
8
  /**
8
9
  * Renders a live JSX export from a .canvas.jsx companion file.
@@ -88,9 +89,25 @@ export default function ComponentWidget({
88
89
  </div>
89
90
  {!interactive && (
90
91
  <div
91
- className={styles.interactOverlay}
92
- onDoubleClick={enterInteractive}
93
- />
92
+ className={overlayStyles.interactOverlay}
93
+ onClick={(e) => {
94
+ // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
95
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
96
+ enterInteractive()
97
+ }}
98
+ role="button"
99
+ tabIndex={0}
100
+ onKeyDown={(e) => {
101
+ if (e.key === 'Enter' || e.key === ' ') {
102
+ e.preventDefault()
103
+ e.stopPropagation()
104
+ enterInteractive()
105
+ }
106
+ }}
107
+ aria-label="Click to interact with component"
108
+ >
109
+ <span className={overlayStyles.interactHint}>Click to interact</span>
110
+ </div>
94
111
  )}
95
112
  {resizable && (
96
113
  <ResizeHandle
@@ -3,6 +3,9 @@
3
3
  overflow: hidden;
4
4
  min-width: 100px;
5
5
  min-height: 60px;
6
+ background: var(--bgColor-default, #ffffff);
7
+ width: 100%;
8
+ height: 100%;
6
9
  }
7
10
 
8
11
  .content {
@@ -16,10 +19,3 @@
16
19
  height: 100%;
17
20
  border: none;
18
21
  }
19
-
20
- .interactOverlay {
21
- position: absolute;
22
- inset: 0;
23
- z-index: 1;
24
- cursor: default;
25
- }
@@ -5,6 +5,7 @@ import { readProp } from './widgetProps.js'
5
5
  import { schemas } from './widgetConfig.js'
6
6
  import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
7
7
  import styles from './FigmaEmbed.module.css'
8
+ import overlayStyles from './embedOverlay.module.css'
8
9
 
9
10
  const figmaEmbedSchema = schemas['figma-embed']
10
11
 
@@ -126,9 +127,25 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
126
127
  </div>
127
128
  {!interactive && !expanded && (
128
129
  <div
129
- className={styles.dragOverlay}
130
- onDoubleClick={enterInteractive}
131
- />
130
+ className={overlayStyles.interactOverlay}
131
+ onClick={(e) => {
132
+ // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
133
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
134
+ enterInteractive()
135
+ }}
136
+ role="button"
137
+ tabIndex={0}
138
+ onKeyDown={(e) => {
139
+ if (e.key === 'Enter' || e.key === ' ') {
140
+ e.preventDefault()
141
+ e.stopPropagation()
142
+ enterInteractive()
143
+ }
144
+ }}
145
+ aria-label="Click to interact with Figma embed"
146
+ >
147
+ <span className={overlayStyles.interactHint}>Click to interact</span>
148
+ </div>
132
149
  )}
133
150
  </>
134
151
  ) : (
@@ -47,13 +47,6 @@
47
47
  display: block;
48
48
  }
49
49
 
50
- .dragOverlay {
51
- position: absolute;
52
- inset: 0;
53
- z-index: 1;
54
- cursor: grab;
55
- }
56
-
57
50
  .resizeHandle {
58
51
  position: absolute;
59
52
  bottom: 0;
@@ -5,6 +5,7 @@ import WidgetWrapper from './WidgetWrapper.jsx'
5
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
6
6
  import { getEmbedChromeVars } from './embedTheme.js'
7
7
  import styles from './PrototypeEmbed.module.css'
8
+ import overlayStyles from './embedOverlay.module.css'
8
9
 
9
10
  function formatName(name) {
10
11
  return name
@@ -401,9 +402,25 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
401
402
  </div>
402
403
  {!interactive && !expanded && (
403
404
  <div
404
- className={styles.dragOverlay}
405
- onDoubleClick={enterInteractive}
406
- />
405
+ className={overlayStyles.interactOverlay}
406
+ onClick={(e) => {
407
+ // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
408
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
409
+ enterInteractive()
410
+ }}
411
+ role="button"
412
+ tabIndex={0}
413
+ onKeyDown={(e) => {
414
+ if (e.key === 'Enter' || e.key === ' ') {
415
+ e.preventDefault()
416
+ e.stopPropagation()
417
+ enterInteractive()
418
+ }
419
+ }}
420
+ aria-label="Click to interact with prototype"
421
+ >
422
+ <span className={overlayStyles.interactHint}>Click to interact</span>
423
+ </div>
407
424
  )}
408
425
  </>
409
426
  ) : (
@@ -18,13 +18,6 @@
18
18
  display: block;
19
19
  }
20
20
 
21
- .dragOverlay {
22
- position: absolute;
23
- inset: 0;
24
- z-index: 1;
25
- cursor: grab;
26
- }
27
-
28
21
  .empty {
29
22
  display: flex;
30
23
  align-items: center;
@@ -411,14 +411,18 @@ export default function WidgetChrome({
411
411
  onUpdate?.({ color })
412
412
  }, [onUpdate])
413
413
 
414
- const showToolbar = !readOnly && (hovered || selected)
414
+ // In readOnly mode, features are already filtered to prod-only by getFeatures.
415
+ // Show toolbar if there are prod features even when readOnly.
416
+ const hasFeatures = features.length > 0
417
+ const showToolbar = (hovered || selected) && (!readOnly || hasFeatures)
415
418
  const showFeatures = showToolbar && !multiSelected
419
+ const menuFeatures = features.filter((f) => f.menu)
416
420
 
417
421
  return (
418
422
  <div
419
423
  className={styles.chromeContainer}
420
- onMouseEnter={readOnly ? undefined : handleMouseEnter}
421
- onMouseLeave={readOnly ? undefined : handleMouseLeave}
424
+ onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
425
+ onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
422
426
  >
423
427
  <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
424
428
  {children}
@@ -495,22 +499,26 @@ export default function WidgetChrome({
495
499
 
496
500
  return null
497
501
  })}
498
- <WidgetOverflowMenu
499
- widgetId={widgetId}
500
- menuFeatures={features.filter((f) => f.menu)}
501
- onAction={onAction}
502
- />
502
+ {menuFeatures.length > 0 && (
503
+ <WidgetOverflowMenu
504
+ widgetId={widgetId}
505
+ menuFeatures={menuFeatures}
506
+ onAction={onAction}
507
+ />
508
+ )}
503
509
  </div>
504
510
  )}
505
511
 
506
- <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
507
- <button
508
- className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
509
- onClick={handleHandleClick}
510
- aria-label={selected ? "Drag to move widget" : "Select widget"}
511
- aria-pressed={selected}
512
- />
513
- </Tooltip>
512
+ {!readOnly && (
513
+ <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
514
+ <button
515
+ className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
516
+ onClick={handleHandleClick}
517
+ aria-label={selected ? "Drag to move widget" : "Select widget"}
518
+ aria-pressed={selected}
519
+ />
520
+ </Tooltip>
521
+ )}
514
522
  </div>
515
523
  </div>
516
524
  </div>
@@ -33,7 +33,7 @@
33
33
  top: calc(100% + 10px);
34
34
  }
35
35
 
36
- /* Trigger dot — centered, visible at rest */
36
+ /* Trigger dot — positioned in the toolbar, visible at rest */
37
37
  .triggerDot {
38
38
  width: 6px;
39
39
  height: 6px;
@@ -41,10 +41,6 @@
41
41
  background: var(--borderColor-muted, #d0d7de);
42
42
  opacity: 0.5;
43
43
  transition: opacity 120ms;
44
- position: absolute;
45
- left: 50%;
46
- top: 50%;
47
- transform: translate(-50%, -50%);
48
44
  }
49
45
 
50
46
  :global([data-sb-canvas-theme^='dark']) .triggerDot {
@@ -235,7 +231,7 @@
235
231
  .overflowMenu {
236
232
  position: absolute;
237
233
  top: calc(100% + 10px);
238
- right: 0;
234
+ left: 0;
239
235
  min-width: max-content;
240
236
  padding: 4px;
241
237
  background: var(--bgColor-default, #ffffff);
@@ -11,6 +11,8 @@
11
11
 
12
12
  .content {
13
13
  position: relative;
14
+ width: 100%;
15
+ height: 100%;
14
16
  }
15
17
 
16
18
  @media (prefers-color-scheme: dark) {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Tests for embed interaction UX (click-to-interact overlay).
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { render, fireEvent, screen } from '@testing-library/react'
6
+ import PrototypeEmbed from './PrototypeEmbed.jsx'
7
+ import FigmaEmbed from './FigmaEmbed.jsx'
8
+ import ComponentWidget from './ComponentWidget.jsx'
9
+
10
+ // Mock buildPrototypeIndex for PrototypeEmbed
11
+ vi.mock('@dfosco/storyboard-core', () => ({
12
+ buildPrototypeIndex: () => ({ folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }),
13
+ }))
14
+
15
+ // Simple mock wrapper for WidgetWrapper
16
+ vi.mock('./WidgetWrapper.jsx', () => ({
17
+ default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
18
+ }))
19
+
20
+ // Mock ResizeHandle
21
+ vi.mock('./ResizeHandle.jsx', () => ({
22
+ default: () => <div data-testid="resize-handle" />,
23
+ }))
24
+
25
+ // Mock ComponentErrorBoundary
26
+ vi.mock('../ComponentErrorBoundary.jsx', () => ({
27
+ default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
28
+ }))
29
+
30
+ describe('Embed interaction overlay', () => {
31
+ describe('PrototypeEmbed', () => {
32
+ const defaultProps = {
33
+ props: { src: '/test', width: 400, height: 300, zoom: 100 },
34
+ onUpdate: vi.fn(),
35
+ resizable: false,
36
+ }
37
+
38
+ it('renders "Click to interact" hint on hover', () => {
39
+ render(<PrototypeEmbed {...defaultProps} />)
40
+
41
+ const hint = screen.getByText('Click to interact')
42
+ expect(hint).toBeInTheDocument()
43
+ // CSS modules mangle class names, just check the element exists
44
+ })
45
+
46
+ it('enters interactive mode on single click (not double-click)', async () => {
47
+ render(<PrototypeEmbed {...defaultProps} />)
48
+
49
+ // Overlay should exist before interaction
50
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
51
+ expect(overlay).toBeInTheDocument()
52
+
53
+ // Single click should remove the overlay (enter interactive mode)
54
+ fireEvent.click(overlay)
55
+
56
+ // Overlay should no longer exist
57
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
61
+ render(<PrototypeEmbed {...defaultProps} />)
62
+
63
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
64
+ fireEvent.click(overlay, { shiftKey: true })
65
+
66
+ // Overlay should still exist (did not enter interactive mode)
67
+ expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
68
+ })
69
+
70
+ it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
71
+ render(<PrototypeEmbed {...defaultProps} />)
72
+
73
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
74
+ fireEvent.click(overlay, { metaKey: true })
75
+
76
+ expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
77
+ })
78
+
79
+ it('supports keyboard interaction (Enter key) with event prevention', () => {
80
+ render(<PrototypeEmbed {...defaultProps} />)
81
+
82
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
83
+ const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
84
+ fireEvent.keyDown(overlay, event)
85
+
86
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
87
+ })
88
+
89
+ it('supports keyboard interaction (Space key) with event prevention', () => {
90
+ render(<PrototypeEmbed {...defaultProps} />)
91
+
92
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
93
+ const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
94
+ fireEvent.keyDown(overlay, event)
95
+
96
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
97
+ })
98
+ })
99
+
100
+ describe('FigmaEmbed', () => {
101
+ const defaultProps = {
102
+ props: { url: 'https://www.figma.com/design/abc123/Test', width: 400, height: 300 },
103
+ onUpdate: vi.fn(),
104
+ resizable: false,
105
+ }
106
+
107
+ it('renders "Click to interact" hint', () => {
108
+ render(<FigmaEmbed {...defaultProps} />)
109
+
110
+ const hint = screen.getByText('Click to interact')
111
+ expect(hint).toBeInTheDocument()
112
+ })
113
+
114
+ it('enters interactive mode on single click', () => {
115
+ render(<FigmaEmbed {...defaultProps} />)
116
+
117
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
118
+ fireEvent.click(overlay)
119
+
120
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
121
+ })
122
+ })
123
+
124
+ describe('ComponentWidget', () => {
125
+ const MockComponent = () => <div>Mock Component</div>
126
+
127
+ const defaultProps = {
128
+ component: MockComponent,
129
+ jsxModule: null,
130
+ exportName: 'MockComponent',
131
+ canvasTheme: 'light',
132
+ isLocalDev: false,
133
+ width: 200,
134
+ height: 150,
135
+ onUpdate: vi.fn(),
136
+ resizable: false,
137
+ }
138
+
139
+ it('renders "Click to interact" hint', () => {
140
+ render(<ComponentWidget {...defaultProps} />)
141
+
142
+ const hint = screen.getByText('Click to interact')
143
+ expect(hint).toBeInTheDocument()
144
+ })
145
+
146
+ it('enters interactive mode on single click', () => {
147
+ render(<ComponentWidget {...defaultProps} />)
148
+
149
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
150
+ fireEvent.click(overlay)
151
+
152
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
153
+ })
154
+ })
155
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared styles for embed interaction overlays.
3
+ * Used by PrototypeEmbed, FigmaEmbed, and ComponentWidget.
4
+ */
5
+
6
+ .interactOverlay {
7
+ position: absolute;
8
+ inset: 0;
9
+ z-index: 1;
10
+ cursor: pointer;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ transition: background-color 150ms ease;
15
+ }
16
+
17
+ .interactOverlay:hover {
18
+ background-color: rgba(0, 0, 0, 0.15);
19
+ }
20
+
21
+ .interactHint {
22
+ opacity: 0;
23
+ color: var(--fgColor-onInverse);
24
+ background-color: var(--bgColor-inverse);
25
+ padding: var(--base-size-12) var(--base-size-16);
26
+ border-radius: var(--base-size-6);
27
+ font-size: 14px;
28
+ font-weight: 600;
29
+ pointer-events: none;
30
+ transition: opacity 150ms ease;
31
+ }
32
+
33
+ .interactOverlay:hover .interactHint {
34
+ opacity: 1;
35
+ }
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
34
34
  if (key === 'items' && Array.isArray(val)) {
35
35
  resolved[key] = val.map((item) => {
36
36
  const r = {}
37
- for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
37
+ for (const [k, v] of Object.entries(item)) {
38
+ // Resolve nested alt object inside items
39
+ if (k === 'alt' && v && typeof v === 'object') {
40
+ const altResolved = {}
41
+ for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
42
+ r[k] = altResolved
43
+ } else {
44
+ r[k] = resolveVar(v)
45
+ }
46
+ }
38
47
  return r
39
48
  })
40
49
  } else if (key === 'alt' && val && typeof val === 'object') {
@@ -103,14 +112,16 @@ export const widgetTypes = buildWidgetTypes()
103
112
 
104
113
  /**
105
114
  * Get the feature list for a widget type.
106
- * In production, only features with `prod: true` are returned.
115
+ * In production (or when isLocalDev is false, e.g. ?prodMode simulation),
116
+ * only features with `prod: true` are returned.
107
117
  * In dev, all features are returned.
108
118
  * @param {string} type — widget type string
119
+ * @param {{ isLocalDev?: boolean }} [options]
109
120
  * @returns {Array} features array from config (variables resolved), or empty array
110
121
  */
111
- export function getFeatures(type) {
122
+ export function getFeatures(type, { isLocalDev = true } = {}) {
112
123
  const features = widgetTypes[type]?.features ?? []
113
- if (import.meta.env?.PROD) {
124
+ if (import.meta.env?.PROD || !isLocalDev) {
114
125
  return features.filter(f => f.prod)
115
126
  }
116
127
  return features
@@ -32,6 +32,25 @@ describe('getFeatures', () => {
32
32
  it('returns empty array for unknown widget types', () => {
33
33
  expect(getFeatures('nonexistent')).toEqual([])
34
34
  })
35
+
36
+ it('returns only prod features when isLocalDev is false', () => {
37
+ const features = getFeatures('figma-embed', { isLocalDev: false })
38
+ expect(features.length).toBeGreaterThan(0)
39
+ expect(features.every(f => f.prod === true)).toBe(true)
40
+ })
41
+
42
+ it('returns all features when isLocalDev is true (default)', () => {
43
+ const allFeatures = getFeatures('figma-embed')
44
+ const prodFeatures = getFeatures('figma-embed', { isLocalDev: false })
45
+ expect(allFeatures.length).toBeGreaterThan(prodFeatures.length)
46
+ })
47
+
48
+ it('includes menu-only prod features when isLocalDev is false', () => {
49
+ const features = getFeatures('figma-embed', { isLocalDev: false })
50
+ const menuFeature = features.find(f => f.menu)
51
+ expect(menuFeature).toBeDefined()
52
+ expect(menuFeature.prod).toBe(true)
53
+ })
35
54
  })
36
55
 
37
56
  describe('getWidgetMeta', () => {
@@ -633,6 +633,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
633
633
  `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
634
634
  `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
635
635
  `export default index`,
636
+ '',
637
+ '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
638
+ 'if (import.meta.hot) {',
639
+ ' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
640
+ ' if (!data) return',
641
+ ' if (data.removed) {',
642
+ ' delete canvases[data.name]',
643
+ ' } else if (data.metadata) {',
644
+ ' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
645
+ ' canvases[data.name] = canvases[data.name]',
646
+ ' ? Object.assign({}, canvases[data.name], data.metadata)',
647
+ ' : data.metadata',
648
+ ' }',
649
+ ' init({ flows, objects, records, prototypes, folders, canvases })',
650
+ ' })',
651
+ '}',
636
652
  ].join('\n')
637
653
  }
638
654
 
@@ -655,10 +671,11 @@ export default function storyboardDataPlugin() {
655
671
  config() {
656
672
  return {
657
673
  optimizeDeps: {
658
- // debug is CJS-only but micromark's development export does
659
- // `import createDebug from 'debug'`. Vite must pre-bundle it
660
- // so the ESM default import resolves correctly.
661
- include: ['debug'],
674
+ // @dfosco/storyboard-react is excluded (virtual module), so Vite
675
+ // can't trace into its deps. Include the remark entry points so
676
+ // Vite pre-bundles the full chain covers all transitive CJS
677
+ // packages (debug, extend, etc.) without whack-a-mole.
678
+ include: ['remark', 'remark-gfm', 'remark-html'],
662
679
  exclude: ['@dfosco/storyboard-react'],
663
680
  },
664
681
  }
@@ -696,7 +713,7 @@ export default function storyboardDataPlugin() {
696
713
  const rawHtml = [
697
714
  '<!DOCTYPE html>',
698
715
  '<html><head>',
699
- '<style>html,body{margin:0;padding:0;width:100%;height:100%}#root{width:100%;height:100%}</style>',
716
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
700
717
  '</head><body>',
701
718
  '<div id="root"></div>',
702
719
  `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
@@ -729,22 +746,50 @@ export default function storyboardDataPlugin() {
729
746
  }
730
747
  }
731
748
 
749
+ // Mark the virtual module as stale so the next page load rebuilds it,
750
+ // but do NOT trigger a full-reload (avoids losing canvas editing state).
751
+ const softInvalidate = () => {
752
+ buildResult = null
753
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
754
+ if (mod) server.moduleGraph.invalidateModule(mod)
755
+ }
756
+
757
+ // Read a canvas file and build HMR metadata for the client-side listener.
758
+ const readCanvasMetadata = (filePath, parsed) => {
759
+ try {
760
+ const absPath = path.resolve(root, filePath)
761
+ const raw = fs.readFileSync(absPath, 'utf-8')
762
+ const materialized = materializeFromText(raw)
763
+ const result = { ...materialized }
764
+ // Inject _route and _folder the same way generateModule does
765
+ if (parsed.inferredRoute) result._route = parsed.inferredRoute
766
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
767
+ if (folderDirMatch) result._folder = folderDirMatch[1]
768
+ return result
769
+ } catch {
770
+ return null
771
+ }
772
+ }
773
+
732
774
  const invalidate = (filePath) => {
733
775
  const normalized = filePath.replace(/\\/g, '/')
734
- // Skip .canvas.jsonl content changes entirely these are mutated
735
- // at runtime by the canvas server API. A full-reload would create
736
- // a feedback loop (save → file change → reload → lose editing state).
737
- // Instead, send a custom HMR event so the active canvas page can refetch
738
- // file-backed data in place with no navigation or document reload.
776
+ // Canvas .jsonl content changes are mutated at runtime by the canvas
777
+ // server API. A full-reload would create a feedback loop (save →
778
+ // file change → reload → lose editing state). Instead, soft-invalidate
779
+ // the virtual module (so page refresh picks up changes) and send a
780
+ // custom HMR event with updated metadata so the canvas page and
781
+ // viewfinder can react in place.
739
782
  if (/\.canvas\.jsonl$/.test(normalized)) {
740
783
  const parsed = parseDataFile(filePath)
741
784
  if (parsed?.suffix === 'canvas' && parsed?.name) {
785
+ const metadata = readCanvasMetadata(filePath, parsed)
742
786
  server.ws.send({
743
787
  type: 'custom',
744
788
  event: 'storyboard:canvas-file-changed',
745
- data: { name: parsed.name },
789
+ data: { name: parsed.name, ...(metadata ? { metadata } : {}) },
746
790
  })
747
791
  }
792
+ softInvalidate()
748
793
  return
749
794
  }
750
795
 
@@ -789,23 +834,27 @@ export default function storyboardDataPlugin() {
789
834
  server.ws.send({
790
835
  type: 'custom',
791
836
  event: 'storyboard:canvas-file-changed',
792
- data: { name },
837
+ data: { name, removed: true },
793
838
  })
839
+ softInvalidate()
794
840
  }, 1500)
795
841
  pendingCanvasUnlinks.set(name, timer)
796
842
  return
797
843
  }
798
844
 
799
845
  if (eventType === 'add') {
846
+ const metadata = readCanvasMetadata(filePath, parsed)
800
847
  const pending = pendingCanvasUnlinks.get(name)
801
848
  if (pending) {
849
+ // unlink+add pair = in-place save (atomic write), not a real remove
802
850
  clearTimeout(pending)
803
851
  pendingCanvasUnlinks.delete(name)
804
852
  server.ws.send({
805
853
  type: 'custom',
806
854
  event: 'storyboard:canvas-file-changed',
807
- data: { name },
855
+ data: { name, ...(metadata ? { metadata } : {}) },
808
856
  })
857
+ softInvalidate()
809
858
  return
810
859
  }
811
860
 
@@ -813,8 +862,9 @@ export default function storyboardDataPlugin() {
813
862
  server.ws.send({
814
863
  type: 'custom',
815
864
  event: 'storyboard:canvas-file-changed',
816
- data: { name },
865
+ data: { name, ...(metadata ? { metadata } : {}) },
817
866
  })
867
+ softInvalidate()
818
868
  return
819
869
  }
820
870
 
@@ -822,8 +872,9 @@ export default function storyboardDataPlugin() {
822
872
  server.ws.send({
823
873
  type: 'custom',
824
874
  event: 'storyboard:canvas-file-changed',
825
- data: { name },
875
+ data: { name, ...(metadata ? { metadata } : {}) },
826
876
  })
877
+ softInvalidate()
827
878
  return
828
879
  }
829
880
  }
@@ -858,18 +909,10 @@ export default function storyboardDataPlugin() {
858
909
  const normalized = ctx.file.replace(/\\/g, '/')
859
910
  if (!/\.canvas\.jsonl$/.test(normalized)) return
860
911
 
861
- const parsed = parseDataFile(ctx.file)
862
- if (parsed?.suffix === 'canvas' && parsed?.name) {
863
- ctx.server.ws.send({
864
- type: 'custom',
865
- event: 'storyboard:canvas-file-changed',
866
- data: { name: parsed.name },
867
- })
868
- }
869
-
870
912
  // Prevent Vite's default fallback behavior (full page reload) for
871
- // non-module .canvas.jsonl edits. Canvas pages consume these updates
872
- // through the custom WS event and in-page refetch.
913
+ // non-module .canvas.jsonl edits. The watcher 'change' handler
914
+ // (invalidate) already sends the custom HMR event and soft-invalidates
915
+ // the virtual module — no duplicate event needed here.
873
916
  return []
874
917
  },
875
918
 
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
@@ -53,10 +53,12 @@ describe('storyboardDataPlugin', () => {
53
53
  expect(config.optimizeDeps.exclude).toContain('@dfosco/storyboard-react')
54
54
  })
55
55
 
56
- it('config() includes debug in optimizeDeps for ESM/CJS interop', () => {
56
+ it('config() includes remark stack in optimizeDeps so Vite pre-bundles transitive CJS deps', () => {
57
57
  const plugin = storyboardDataPlugin()
58
58
  const config = plugin.config()
59
- expect(config.optimizeDeps.include).toContain('debug')
59
+ expect(config.optimizeDeps.include).toContain('remark')
60
+ expect(config.optimizeDeps.include).toContain('remark-gfm')
61
+ expect(config.optimizeDeps.include).toContain('remark-html')
60
62
  })
61
63
 
62
64
  it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
@@ -827,3 +829,220 @@ describe('template variable integration', () => {
827
829
  warnSpy.mockRestore()
828
830
  })
829
831
  })
832
+
833
+ // ── Canvas watcher / HMR tests ──────────────────────────────────────
834
+
835
+ describe('canvas watcher behavior', () => {
836
+ /** Helper: create a mock Vite dev server for configureServer */
837
+ function createMockServer(root) {
838
+ const listeners = {}
839
+ const wsSent = []
840
+ const invalidatedModules = []
841
+
842
+ return {
843
+ wsSent,
844
+ invalidatedModules,
845
+ listeners,
846
+ config: { root, base: '/' },
847
+ watcher: {
848
+ add: vi.fn(),
849
+ on(event, fn) {
850
+ if (!listeners[event]) listeners[event] = []
851
+ listeners[event].push(fn)
852
+ },
853
+ },
854
+ moduleGraph: {
855
+ getModuleById(id) {
856
+ if (id === RESOLVED_ID) return { id: RESOLVED_ID }
857
+ return null
858
+ },
859
+ invalidateModule(mod) {
860
+ invalidatedModules.push(mod.id)
861
+ },
862
+ },
863
+ ws: {
864
+ send(msg) { wsSent.push(msg) },
865
+ },
866
+ middlewares: {
867
+ use: vi.fn(),
868
+ },
869
+ }
870
+ }
871
+
872
+ /** Emit a watcher event on the mock server */
873
+ function emit(server, event, filePath) {
874
+ for (const fn of (server.listeners[event] || [])) {
875
+ fn(filePath)
876
+ }
877
+ }
878
+
879
+ function writeCanvasFile(dir, name, title) {
880
+ const canvasDir = path.join(dir, 'src', 'canvas')
881
+ mkdirSync(canvasDir, { recursive: true })
882
+ const evt = { event: 'canvas_created', title: title || name, timestamp: Date.now() }
883
+ writeFileSync(path.join(canvasDir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
884
+ }
885
+
886
+ it('soft-invalidates virtual module on canvas content change (no full-reload)', () => {
887
+ writeCanvasFile(tmpDir, 'test-canvas', 'Original Title')
888
+ const plugin = createPlugin()
889
+ // Force initial buildResult
890
+ plugin.load(RESOLVED_ID)
891
+
892
+ const server = createMockServer(tmpDir)
893
+ plugin.configureServer(server)
894
+
895
+ // Simulate a canvas file content change
896
+ const canvasPath = path.join(tmpDir, 'src', 'canvas', 'test-canvas.canvas.jsonl')
897
+ emit(server, 'change', canvasPath)
898
+
899
+ // Should have sent custom HMR event (not full-reload)
900
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
901
+ const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
902
+
903
+ expect(customEvents.length).toBe(1)
904
+ expect(customEvents[0].event).toBe('storyboard:canvas-file-changed')
905
+ expect(customEvents[0].data.name).toBe('test-canvas')
906
+ expect(fullReloads.length).toBe(0)
907
+
908
+ // Should have invalidated the virtual module
909
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
910
+ })
911
+
912
+ it('includes metadata in HMR event for canvas content changes', () => {
913
+ writeCanvasFile(tmpDir, 'meta-canvas', 'My Canvas Title')
914
+ const plugin = createPlugin()
915
+ plugin.load(RESOLVED_ID)
916
+
917
+ const server = createMockServer(tmpDir)
918
+ plugin.configureServer(server)
919
+
920
+ emit(server, 'change', path.join(tmpDir, 'src', 'canvas', 'meta-canvas.canvas.jsonl'))
921
+
922
+ const event = server.wsSent.find(m => m.type === 'custom')
923
+ expect(event.data.metadata).toBeDefined()
924
+ expect(event.data.metadata.title).toBe('My Canvas Title')
925
+ })
926
+
927
+ it('soft-invalidates on canvas file add (new canvas)', () => {
928
+ const plugin = createPlugin()
929
+ plugin.load(RESOLVED_ID)
930
+
931
+ const server = createMockServer(tmpDir)
932
+ plugin.configureServer(server)
933
+
934
+ // Create the file after the server is configured
935
+ writeCanvasFile(tmpDir, 'new-canvas', 'Brand New')
936
+ emit(server, 'add', path.join(tmpDir, 'src', 'canvas', 'new-canvas.canvas.jsonl'))
937
+
938
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
939
+ const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
940
+
941
+ expect(customEvents.length).toBe(1)
942
+ expect(customEvents[0].data.name).toBe('new-canvas')
943
+ expect(customEvents[0].data.metadata).toBeDefined()
944
+ expect(fullReloads.length).toBe(0)
945
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
946
+ })
947
+
948
+ it('soft-invalidates on canvas file unlink after timeout (true delete)', async () => {
949
+ writeCanvasFile(tmpDir, 'doomed-canvas', 'Gone Soon')
950
+ const plugin = createPlugin()
951
+ plugin.load(RESOLVED_ID)
952
+
953
+ const server = createMockServer(tmpDir)
954
+ plugin.configureServer(server)
955
+
956
+ emit(server, 'unlink', path.join(tmpDir, 'src', 'canvas', 'doomed-canvas.canvas.jsonl'))
957
+
958
+ // Immediately after unlink — no event yet (deferred by 1500ms)
959
+ expect(server.wsSent.length).toBe(0)
960
+
961
+ // Wait for deferred timer
962
+ await new Promise(resolve => setTimeout(resolve, 1600))
963
+
964
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
965
+ expect(customEvents.length).toBe(1)
966
+ expect(customEvents[0].data.name).toBe('doomed-canvas')
967
+ expect(customEvents[0].data.removed).toBe(true)
968
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
969
+ })
970
+
971
+ it('cancels deferred unlink on add (atomic write / in-place save)', async () => {
972
+ writeCanvasFile(tmpDir, 'saved-canvas', 'Saved')
973
+ const plugin = createPlugin()
974
+ plugin.load(RESOLVED_ID)
975
+
976
+ const server = createMockServer(tmpDir)
977
+ plugin.configureServer(server)
978
+
979
+ const canvasPath = path.join(tmpDir, 'src', 'canvas', 'saved-canvas.canvas.jsonl')
980
+
981
+ // Simulate atomic write: unlink then add within 1500ms
982
+ emit(server, 'unlink', canvasPath)
983
+ emit(server, 'add', canvasPath)
984
+
985
+ // Should have sent one event immediately (the add cancelling the unlink)
986
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
987
+ expect(customEvents.length).toBe(1)
988
+ expect(customEvents[0].data.name).toBe('saved-canvas')
989
+ expect(customEvents[0].data.removed).toBeUndefined()
990
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
991
+
992
+ // Wait past the unlink timer — should NOT get a second event
993
+ await new Promise(resolve => setTimeout(resolve, 1600))
994
+ const allCustom = server.wsSent.filter(m => m.type === 'custom')
995
+ expect(allCustom.length).toBe(1)
996
+ })
997
+
998
+ it('handleHotUpdate returns empty array for canvas files (suppresses full-reload)', () => {
999
+ const plugin = createPlugin()
1000
+ const result = plugin.handleHotUpdate({
1001
+ file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
1002
+ server: createMockServer(tmpDir),
1003
+ modules: [],
1004
+ })
1005
+ expect(result).toEqual([])
1006
+ })
1007
+
1008
+ it('handleHotUpdate does not send duplicate HMR events', () => {
1009
+ const plugin = createPlugin()
1010
+ const server = createMockServer(tmpDir)
1011
+ plugin.handleHotUpdate({
1012
+ file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
1013
+ server,
1014
+ modules: [],
1015
+ })
1016
+ // handleHotUpdate should NOT send events (invalidate() handles it)
1017
+ expect(server.wsSent.length).toBe(0)
1018
+ })
1019
+
1020
+ it('generated virtual module includes HMR listener for canvas updates', () => {
1021
+ writeCanvasFile(tmpDir, 'hmr-canvas', 'HMR Test')
1022
+ const plugin = createPlugin()
1023
+ const code = plugin.load(RESOLVED_ID)
1024
+
1025
+ expect(code).toContain('import.meta.hot')
1026
+ expect(code).toContain('storyboard:canvas-file-changed')
1027
+ expect(code).toContain('data.removed')
1028
+ expect(code).toContain('data.metadata')
1029
+ // Should merge into existing entries to preserve build-time fields
1030
+ expect(code).toContain('Object.assign')
1031
+ })
1032
+
1033
+ it('page refresh after canvas add yields updated module with new canvas', () => {
1034
+ const plugin = createPlugin()
1035
+ // First load — no canvases
1036
+ const code1 = plugin.load(RESOLVED_ID)
1037
+ expect(code1).not.toContain('"refresh-canvas"')
1038
+
1039
+ // Simulate adding a canvas and clearing buildResult (what softInvalidate does)
1040
+ writeCanvasFile(tmpDir, 'refresh-canvas', 'After Refresh')
1041
+
1042
+ // Manually clear buildResult by loading a fresh plugin instance with the same root
1043
+ const plugin2 = createPlugin()
1044
+ const code2 = plugin2.load(RESOLVED_ID)
1045
+ expect(code2).toContain('"refresh-canvas"')
1046
+ expect(code2).toContain('After Refresh')
1047
+ })
1048
+ })