@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.40

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 (63) hide show
  1. package/package.json +9 -4
  2. package/src/Icon.jsx +179 -0
  3. package/src/Viewfinder.jsx +1030 -57
  4. package/src/Viewfinder.module.css +1524 -155
  5. package/src/canvas/CanvasControls.jsx +51 -2
  6. package/src/canvas/CanvasControls.module.css +31 -0
  7. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  8. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  9. package/src/canvas/CanvasPage.jsx +843 -301
  10. package/src/canvas/CanvasPage.module.css +73 -50
  11. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  12. package/src/canvas/CanvasToolbar.jsx +2 -2
  13. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  14. package/src/canvas/PageSelector.jsx +198 -0
  15. package/src/canvas/PageSelector.module.css +158 -0
  16. package/src/canvas/PageSelector.test.jsx +104 -0
  17. package/src/canvas/canvasApi.js +22 -8
  18. package/src/canvas/canvasReloadGuard.js +37 -0
  19. package/src/canvas/canvasReloadGuard.test.js +27 -0
  20. package/src/canvas/componentIsolate.jsx +135 -0
  21. package/src/canvas/useCanvas.js +15 -10
  22. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  23. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  25. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  26. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  27. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  28. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  29. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  30. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  31. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  32. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  33. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  34. package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
  35. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  36. package/src/canvas/widgets/StickyNote.module.css +5 -0
  37. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  38. package/src/canvas/widgets/StoryWidget.jsx +276 -0
  39. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  40. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  41. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  42. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  43. package/src/canvas/widgets/codepenUrl.js +75 -0
  44. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  45. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  46. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  47. package/src/canvas/widgets/embedTheme.js +56 -0
  48. package/src/canvas/widgets/githubUrl.js +82 -0
  49. package/src/canvas/widgets/githubUrl.test.js +74 -0
  50. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  51. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  52. package/src/canvas/widgets/index.js +4 -0
  53. package/src/canvas/widgets/pasteRules.js +295 -0
  54. package/src/canvas/widgets/pasteRules.test.js +474 -0
  55. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +375 -57
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import PageSelector from './PageSelector.jsx'
4
+
5
+ const PAGES = [
6
+ { name: 'research/interviews', route: '/canvas/research/interviews', title: 'Interviews' },
7
+ { name: 'research/surveys', route: '/canvas/research/surveys', title: 'Surveys' },
8
+ { name: 'research/analysis', route: '/canvas/research/analysis', title: 'Analysis' },
9
+ ]
10
+
11
+ describe('PageSelector', () => {
12
+ beforeEach(() => {
13
+ // Reset location mock
14
+ delete window.location
15
+ window.location = { href: '' }
16
+ })
17
+
18
+ it('renders nothing when fewer than 2 pages', () => {
19
+ const { container } = render(<PageSelector currentName="solo" pages={[{ name: 'solo', route: '/canvas/solo', title: 'Solo' }]} />)
20
+ expect(container.innerHTML).toBe('')
21
+ })
22
+
23
+ it('renders nothing when pages is empty', () => {
24
+ const { container } = render(<PageSelector currentName="foo" pages={[]} />)
25
+ expect(container.innerHTML).toBe('')
26
+ })
27
+
28
+ it('shows current page label and page count', () => {
29
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
30
+ expect(screen.getByText('Interviews')).toBeTruthy()
31
+ expect(screen.getByText('1/3')).toBeTruthy()
32
+ })
33
+
34
+ it('shows correct index for non-first page', () => {
35
+ render(<PageSelector currentName="research/surveys" pages={PAGES} />)
36
+ expect(screen.getByText('Surveys')).toBeTruthy()
37
+ expect(screen.getByText('2/3')).toBeTruthy()
38
+ })
39
+
40
+ it('opens dropdown on click and shows all pages', () => {
41
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
42
+ const trigger = screen.getByTitle('Switch canvas page')
43
+ fireEvent.click(trigger)
44
+
45
+ const options = screen.getAllByRole('option')
46
+ expect(options).toHaveLength(3)
47
+ expect(options[0].textContent).toBe('Interviews')
48
+ expect(options[1].textContent).toBe('Surveys')
49
+ expect(options[2].textContent).toBe('Analysis')
50
+ })
51
+
52
+ it('marks the current page as active', () => {
53
+ render(<PageSelector currentName="research/surveys" pages={PAGES} />)
54
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
55
+
56
+ const options = screen.getAllByRole('option')
57
+ expect(options[1].getAttribute('aria-selected')).toBe('true')
58
+ expect(options[0].getAttribute('aria-selected')).toBe('false')
59
+ })
60
+
61
+ it('navigates to selected page', () => {
62
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
63
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
64
+ // Click the option in the menu (not the trigger label)
65
+ const options = screen.getAllByRole('option')
66
+ fireEvent.click(options[1]) // Surveys
67
+
68
+ expect(window.location.href).toContain('/canvas/research/surveys')
69
+ })
70
+
71
+ it('closes dropdown on Escape', () => {
72
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
73
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
74
+ expect(screen.queryByRole('listbox')).toBeTruthy()
75
+
76
+ fireEvent.keyDown(document, { key: 'Escape' })
77
+ expect(screen.queryByRole('listbox')).toBeNull()
78
+ })
79
+
80
+ it('closes dropdown on outside click', () => {
81
+ render(
82
+ <div>
83
+ <PageSelector currentName="research/interviews" pages={PAGES} />
84
+ <span data-testid="outside">Outside</span>
85
+ </div>
86
+ )
87
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
88
+ expect(screen.queryByRole('listbox')).toBeTruthy()
89
+
90
+ fireEvent.mouseDown(screen.getByTestId('outside'))
91
+ expect(screen.queryByRole('listbox')).toBeNull()
92
+ })
93
+
94
+ it('does not navigate when selecting the current page', () => {
95
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
96
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
97
+ // Click the current page option
98
+ const options = screen.getAllByRole('option')
99
+ fireEvent.click(options[0]) // Interviews (current)
100
+
101
+ // location.href was set to '' initially, should remain unchanged
102
+ expect(window.location.href).toBe('')
103
+ })
104
+ })
@@ -28,22 +28,36 @@ export function createCanvas(data) {
28
28
  return request('/create', 'POST', data)
29
29
  }
30
30
 
31
- export function updateCanvas(name, { widgets, sources, settings }) {
32
- return request('/update', 'PUT', { name, widgets, sources, settings })
31
+ export function updateCanvas(canvasId, { widgets, sources, settings }) {
32
+ return request('/update', 'PUT', { name: canvasId, widgets, sources, settings })
33
33
  }
34
34
 
35
- export function addWidget(name, { type, props, position }) {
36
- return request('/widget', 'POST', { name, type, props, position })
35
+ export function addWidget(canvasId, { type, props, position }) {
36
+ return request('/widget', 'POST', { name: canvasId, type, props, position })
37
37
  }
38
38
 
39
- export function removeWidget(name, widgetId) {
40
- return request('/widget', 'DELETE', { name, widgetId })
39
+ export function removeWidget(canvasId, widgetId) {
40
+ return request('/widget', 'DELETE', { name: canvasId, widgetId })
41
41
  }
42
42
 
43
- export function uploadImage(dataUrl, canvasName) {
44
- return request('/image', 'POST', { dataUrl, canvasName })
43
+ export function uploadImage(dataUrl, canvasId, filename) {
44
+ const body = { dataUrl, canvasName: canvasId }
45
+ if (filename) body.filename = filename
46
+ return request('/image', 'POST', body)
45
47
  }
46
48
 
47
49
  export function toggleImagePrivacy(filename) {
48
50
  return request('/image/toggle-private', 'POST', { filename })
49
51
  }
52
+
53
+ export function getCanvas(canvasId) {
54
+ return request(`/read?name=${encodeURIComponent(canvasId)}`, 'GET')
55
+ }
56
+
57
+ export function checkGitHubCliAvailable() {
58
+ return request('/github/available', 'GET')
59
+ }
60
+
61
+ export function fetchGitHubEmbed(url) {
62
+ return request('/github/embed', 'POST', { url })
63
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Canvas reload guard — client-side state for preventing HMR full reloads.
3
+ *
4
+ * This module tracks whether a canvas is currently active. When active,
5
+ * the Vite plugin suppresses full-page reloads to preserve canvas state.
6
+ *
7
+ * The actual guard logic is implemented in:
8
+ * - Server: vite.config.js (ws.send monkey-patch + heartbeat)
9
+ * - Client: CanvasPage.jsx (vite:beforeFullReload + vite:ws:disconnect)
10
+ *
11
+ * This module provides the state that those systems check.
12
+ */
13
+
14
+ let active = false
15
+
16
+ /**
17
+ * Enable the canvas reload guard.
18
+ * Call when a canvas page mounts.
19
+ */
20
+ export function enableCanvasGuard() {
21
+ active = true
22
+ }
23
+
24
+ /**
25
+ * Disable the canvas reload guard.
26
+ * Call when a canvas page unmounts.
27
+ */
28
+ export function disableCanvasGuard() {
29
+ active = false
30
+ }
31
+
32
+ /**
33
+ * Check if the canvas reload guard is currently active.
34
+ */
35
+ export function isCanvasGuardActive() {
36
+ return active
37
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
3
+
4
+ describe('canvasReloadGuard', () => {
5
+ beforeEach(() => {
6
+ disableCanvasGuard()
7
+ })
8
+
9
+ it('starts inactive', () => {
10
+ expect(isCanvasGuardActive()).toBe(false)
11
+ })
12
+
13
+ it('can be enabled and disabled', () => {
14
+ enableCanvasGuard()
15
+ expect(isCanvasGuardActive()).toBe(true)
16
+ disableCanvasGuard()
17
+ expect(isCanvasGuardActive()).toBe(false)
18
+ })
19
+
20
+ it('enable is idempotent', () => {
21
+ enableCanvasGuard()
22
+ enableCanvasGuard()
23
+ expect(isCanvasGuardActive()).toBe(true)
24
+ disableCanvasGuard()
25
+ expect(isCanvasGuardActive()).toBe(false)
26
+ })
27
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Canvas Component Isolate — iframe entry point.
3
+ *
4
+ * Renders a single named export from a .canvas.jsx module inside an
5
+ * isolated document. The parent CanvasPage embeds this via an iframe
6
+ * so a broken component cannot crash the entire canvas.
7
+ *
8
+ * Query params:
9
+ * module — absolute or base-relative path to the .canvas.jsx file
10
+ * export — the named export to render
11
+ * theme — canvas theme (light / dark / dark_dimmed)
12
+ */
13
+ import { createElement, Component as ReactComponent } from 'react'
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'
34
+
35
+ // ── Error Boundary ──────────────────────────────────────────────────
36
+ class IsolateErrorBoundary extends ReactComponent {
37
+ constructor(props) {
38
+ super(props)
39
+ this.state = { error: null }
40
+ }
41
+ static getDerivedStateFromError(error) {
42
+ return { error }
43
+ }
44
+ render() {
45
+ if (this.state.error) {
46
+ return createElement('div', { style: errorStyle },
47
+ createElement('strong', null, this.props.name || 'Component'),
48
+ createElement('br'),
49
+ String(this.state.error.message || this.state.error),
50
+ )
51
+ }
52
+ return this.props.children
53
+ }
54
+ }
55
+
56
+ // ── Styles ──────────────────────────────────────────────────────────
57
+ const errorStyle = {
58
+ padding: '16px',
59
+ color: '#cf222e',
60
+ fontFamily: 'system-ui, -apple-system, sans-serif',
61
+ fontSize: '13px',
62
+ lineHeight: 1.5,
63
+ whiteSpace: 'pre-wrap',
64
+ wordBreak: 'break-word',
65
+ }
66
+
67
+ // ── Resolve module path (mirrors useCanvas.resolveCanvasModuleImport) ─
68
+ function resolveModulePath(raw) {
69
+ if (!raw) return raw
70
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
71
+ if (!raw.startsWith('/')) return raw
72
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
73
+ if (!base) return raw
74
+ if (raw.startsWith(base)) return raw
75
+ return `${base}${raw}`
76
+ }
77
+
78
+ // ── Main ────────────────────────────────────────────────────────────
79
+ const params = new URLSearchParams(window.location.search)
80
+ const modulePath = params.get('module')
81
+ const exportName = params.get('export')
82
+ const theme = params.get('theme') || 'light'
83
+
84
+ // Map theme to Primer colorMode
85
+ const colorMode = theme.startsWith('dark') ? 'night' : 'day'
86
+
87
+ // Apply theme to document for Primer / CSS-var inheritance
88
+ document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
89
+ document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
90
+ document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
91
+
92
+ const root = createRoot(document.getElementById('root'))
93
+
94
+ async function mount() {
95
+ if (!modulePath || !exportName) {
96
+ root.render(createElement('div', { style: errorStyle }, 'Missing module or export param'))
97
+ return
98
+ }
99
+
100
+ // Validate: only allow .canvas.jsx and .story.{jsx,tsx} modules
101
+ if (!modulePath.endsWith('.canvas.jsx') && !modulePath.match(/\.story\.(jsx|tsx)$/)) {
102
+ root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx and .story.jsx/.tsx files are allowed'))
103
+ return
104
+ }
105
+
106
+ try {
107
+ const resolved = resolveModulePath(modulePath)
108
+ const mod = await import(/* @vite-ignore */ resolved)
109
+ const Component = mod[exportName]
110
+
111
+ if (!Component || typeof Component !== 'function') {
112
+ throw new Error(`Export "${exportName}" not found or is not a component`)
113
+ }
114
+
115
+ root.render(
116
+ createElement(ThemeProvider, { colorMode },
117
+ createElement(BaseStyles, null,
118
+ createElement(IsolateErrorBoundary, { name: exportName },
119
+ createElement(Component),
120
+ ),
121
+ ),
122
+ ),
123
+ )
124
+ } catch (err) {
125
+ root.render(
126
+ createElement('div', { style: errorStyle },
127
+ createElement('strong', null, exportName),
128
+ createElement('br'),
129
+ String(err.message || err),
130
+ ),
131
+ )
132
+ }
133
+ }
134
+
135
+ mount()
@@ -31,13 +31,14 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
31
31
  * Uses build-time data for static config (routes, JSX path), but fetches
32
32
  * fresh widget data from the server to pick up persisted edits.
33
33
  *
34
- * @param {string} name - Canvas name as indexed by the data plugin
35
- * @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
34
+ * @param {string} canvasId - Canonical canvas ID as indexed by the data plugin
35
+ * @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
36
36
  */
37
- export function useCanvas(name) {
38
- const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
37
+ export function useCanvas(canvasId) {
38
+ const buildTimeCanvas = useMemo(() => getCanvasData(canvasId), [canvasId])
39
39
  const [canvas, setCanvas] = useState(buildTimeCanvas)
40
40
  const [jsxExports, setJsxExports] = useState(null)
41
+ const [jsxError, setJsxError] = useState(false)
41
42
  const [loading, setLoading] = useState(true)
42
43
 
43
44
  // Fetch fresh data from server on mount
@@ -49,7 +50,7 @@ export function useCanvas(name) {
49
50
  }
50
51
 
51
52
  setLoading(true)
52
- fetchCanvasFromServer(name).then((fresh) => {
53
+ fetchCanvasFromServer(canvasId).then((fresh) => {
53
54
  if (fresh) {
54
55
  // Merge: use server data for widgets/sources, keep build-time for _route/_jsxModule
55
56
  setCanvas({ ...buildTimeCanvas, ...fresh })
@@ -58,7 +59,7 @@ export function useCanvas(name) {
58
59
  }
59
60
  setLoading(false)
60
61
  })
61
- }, [name, buildTimeCanvas])
62
+ }, [canvasId, buildTimeCanvas])
62
63
 
63
64
  const jsxModule = canvas?._jsxModule
64
65
  const jsxImport = canvas?._jsxImport
@@ -66,6 +67,7 @@ export function useCanvas(name) {
66
67
  useEffect(() => {
67
68
  if (!jsxModule) {
68
69
  setJsxExports(null)
70
+ setJsxError(false)
69
71
  return
70
72
  }
71
73
 
@@ -82,10 +84,12 @@ export function useCanvas(name) {
82
84
  }
83
85
  }
84
86
  setJsxExports(exports)
87
+ setJsxError(false)
85
88
  })
86
89
  .catch((err) => {
87
90
  console.error(`[storyboard] Failed to load canvas JSX module: ${jsxModule}`, err)
88
91
  setJsxExports(null)
92
+ setJsxError(true)
89
93
  })
90
94
  }, [jsxModule, jsxImport])
91
95
 
@@ -95,8 +99,9 @@ export function useCanvas(name) {
95
99
  if (!import.meta.hot || !buildTimeCanvas) return
96
100
 
97
101
  const handleCanvasFileChanged = ({ data }) => {
98
- if (!data || data.name !== name) return
99
- fetchCanvasFromServer(name).then((fresh) => {
102
+ const eventId = data?.canvasId || data?.name
103
+ if (!data || eventId !== canvasId) return
104
+ fetchCanvasFromServer(canvasId).then((fresh) => {
100
105
  if (fresh) {
101
106
  setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
102
107
  }
@@ -107,7 +112,7 @@ export function useCanvas(name) {
107
112
  return () => {
108
113
  import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
109
114
  }
110
- }, [name, buildTimeCanvas])
115
+ }, [canvasId, buildTimeCanvas])
111
116
 
112
- return { canvas, jsxExports, loading }
117
+ return { canvas, jsxExports, jsxError, loading }
113
118
  }