@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.20

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 (44) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. package/src/vite/data-plugin.test.js +405 -5
@@ -44,58 +44,14 @@
44
44
  gap: 8px;
45
45
  }
46
46
 
47
- .canvasTitleWrap {
48
- display: inline-grid;
49
- }
50
-
51
- .canvasTitleWrap > * {
52
- grid-area: 1 / 1;
53
- }
54
-
55
- .canvasTitleMeasure {
56
- visibility: hidden;
57
- white-space: pre;
58
- font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
59
- font-size: 14px;
60
- font-weight: 600;
61
- padding: 4px 8px;
62
- border: 1px solid transparent;
63
- min-width: 80px;
64
- pointer-events: none;
65
- }
66
-
67
- .canvasTitleInput {
47
+ .canvasTitleStatic {
68
48
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
69
49
  font-size: 14px;
70
50
  font-weight: 600;
71
51
  color: var(--fgColor-muted, #656d76);
72
- background: transparent;
73
- border: 1px solid transparent;
74
- border-radius: 6px;
75
- padding: 4px 8px;
76
52
  margin: 0;
77
- outline: none;
78
- width: 100%;
79
- min-width: 0;
80
- transition: border-color 150ms, background-color 150ms, color 150ms;
81
- }
82
-
83
- .canvasTitleInput:hover {
84
- color: var(--fgColor-default, #1f2328);
85
- border-color: var(--borderColor-default, #d1d9e0);
86
- background: var(--bgColor-default, #ffffff);
87
- }
88
-
89
- .canvasTitleInput:focus {
90
- color: var(--fgColor-default, #1f2328);
91
- border-color: var(--bgColor-accent-emphasis, #2f81f7);
92
- background: var(--bgColor-default, #ffffff);
93
- }
94
-
95
- .canvasTitleStatic {
96
- composes: canvasTitleInput;
97
- cursor: default;
98
- pointer-events: none;
53
+ padding: 4px 8px;
54
+ white-space: nowrap;
99
55
  }
100
56
 
101
57
  /* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
@@ -103,6 +59,12 @@
103
59
  overflow: visible;
104
60
  }
105
61
 
62
+ /* Elevate stacking context for hovered/selected widgets so their chrome
63
+ (toolbar, menus, selection outline) renders above sibling widgets. */
64
+ :global(.tc-drag:has([data-tc-elevated])) {
65
+ z-index: 1;
66
+ }
67
+
106
68
  .localEditingLabel {
107
69
  display: inline-flex;
108
70
  align-items: center;
@@ -0,0 +1,50 @@
1
+ import { Component } from 'react'
2
+
3
+ /**
4
+ * Error boundary for canvas component widgets.
5
+ * Catches render-time errors so a single broken component
6
+ * doesn't crash the entire canvas page.
7
+ *
8
+ * Used as a production fallback when iframe isolation is not available.
9
+ */
10
+ export default class ComponentErrorBoundary extends Component {
11
+ constructor(props) {
12
+ super(props)
13
+ this.state = { error: null }
14
+ }
15
+
16
+ static getDerivedStateFromError(error) {
17
+ return { error }
18
+ }
19
+
20
+ componentDidCatch(error, info) {
21
+ console.error(
22
+ `[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
23
+ error,
24
+ info?.componentStack,
25
+ )
26
+ }
27
+
28
+ render() {
29
+ if (this.state.error) {
30
+ return (
31
+ <div style={{
32
+ padding: '16px',
33
+ color: '#cf222e',
34
+ fontFamily: 'system-ui, -apple-system, sans-serif',
35
+ fontSize: '13px',
36
+ lineHeight: 1.5,
37
+ whiteSpace: 'pre-wrap',
38
+ wordBreak: 'break-word',
39
+ minWidth: 200,
40
+ minHeight: 60,
41
+ }}>
42
+ <strong>{this.props.name || 'Component'}</strong>
43
+ <br />
44
+ {String(this.state.error.message || this.state.error)}
45
+ </div>
46
+ )
47
+ }
48
+ return this.props.children
49
+ }
50
+ }
@@ -0,0 +1,102 @@
1
+ import { useCallback, useRef, useState, useEffect } from 'react'
2
+ import styles from './PageSelector.module.css'
3
+
4
+ /**
5
+ * In-canvas page selector — shows sibling pages in the same canvas group.
6
+ * Only renders when 2+ sibling pages exist.
7
+ * Uses window.location for navigation to avoid requiring a Router context.
8
+ *
9
+ * @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }> }} props
10
+ */
11
+ export default function PageSelector({ currentName, pages }) {
12
+ const [open, setOpen] = useState(false)
13
+ const containerRef = useRef(null)
14
+
15
+ const currentPage = pages.find((p) => p.name === currentName)
16
+ const currentLabel = currentPage?.title || currentName.split('/').pop()
17
+ const currentIndex = pages.findIndex((p) => p.name === currentName)
18
+
19
+ const handleSelect = useCallback(
20
+ (page) => {
21
+ if (page.name !== currentName) {
22
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
23
+ window.location.href = base + page.route
24
+ }
25
+ setOpen(false)
26
+ },
27
+ [currentName],
28
+ )
29
+
30
+ // Close on outside click
31
+ useEffect(() => {
32
+ if (!open) return
33
+ function handleClick(e) {
34
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
35
+ setOpen(false)
36
+ }
37
+ }
38
+ document.addEventListener('mousedown', handleClick)
39
+ return () => document.removeEventListener('mousedown', handleClick)
40
+ }, [open])
41
+
42
+ // Close on Escape
43
+ useEffect(() => {
44
+ if (!open) return
45
+ function handleKey(e) {
46
+ if (e.key === 'Escape') setOpen(false)
47
+ }
48
+ document.addEventListener('keydown', handleKey)
49
+ return () => document.removeEventListener('keydown', handleKey)
50
+ }, [open])
51
+
52
+ if (!pages || pages.length < 2) return null
53
+
54
+ return (
55
+ <nav ref={containerRef} className={styles.container} aria-label="Canvas pages">
56
+ <button
57
+ className={styles.trigger}
58
+ onClick={() => setOpen((v) => !v)}
59
+ aria-expanded={open}
60
+ aria-haspopup="listbox"
61
+ title="Switch canvas page"
62
+ >
63
+ <span className={styles.label}>{currentLabel}</span>
64
+ <span className={styles.badge}>
65
+ {currentIndex + 1}/{pages.length}
66
+ </span>
67
+ <svg
68
+ className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
69
+ width="12"
70
+ height="12"
71
+ viewBox="0 0 12 12"
72
+ fill="none"
73
+ aria-hidden="true"
74
+ >
75
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
76
+ </svg>
77
+ </button>
78
+ {open && (
79
+ <ul className={styles.menu} role="listbox" aria-label="Canvas pages">
80
+ {pages.map((page) => (
81
+ <li
82
+ key={page.name}
83
+ role="option"
84
+ aria-selected={page.name === currentName}
85
+ className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
86
+ onClick={() => handleSelect(page)}
87
+ onKeyDown={(e) => {
88
+ if (e.key === 'Enter' || e.key === ' ') {
89
+ e.preventDefault()
90
+ handleSelect(page)
91
+ }
92
+ }}
93
+ tabIndex={0}
94
+ >
95
+ {page.title}
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ )}
100
+ </nav>
101
+ )
102
+ }
@@ -0,0 +1,93 @@
1
+ .container {
2
+ position: relative;
3
+ font-size: 13px;
4
+ }
5
+
6
+ .trigger {
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 6px;
10
+ padding: 5px 10px;
11
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
12
+ border-radius: 6px;
13
+ background: var(--bgColor-default, #fff);
14
+ color: var(--fgColor-default, #1f2328);
15
+ cursor: pointer;
16
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
17
+ transition: border-color 0.15s, box-shadow 0.15s;
18
+ line-height: 1;
19
+ }
20
+
21
+ .trigger:hover {
22
+ border-color: var(--borderColor-emphasis, rgba(0, 0, 0, 0.3));
23
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12);
24
+ }
25
+
26
+ .label {
27
+ font-weight: 600;
28
+ max-width: 200px;
29
+ overflow: hidden;
30
+ text-overflow: ellipsis;
31
+ white-space: nowrap;
32
+ }
33
+
34
+ .badge {
35
+ font-size: 11px;
36
+ color: var(--fgColor-muted, #656d76);
37
+ font-variant-numeric: tabular-nums;
38
+ }
39
+
40
+ .chevron {
41
+ color: var(--fgColor-muted, #656d76);
42
+ transition: transform 0.15s;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .chevronOpen {
47
+ transform: rotate(180deg);
48
+ }
49
+
50
+ .menu {
51
+ position: absolute;
52
+ top: calc(100% + 4px);
53
+ left: 0;
54
+ min-width: 180px;
55
+ max-width: 300px;
56
+ max-height: 320px;
57
+ overflow-y: auto;
58
+ margin: 0;
59
+ padding: 4px;
60
+ list-style: none;
61
+ background: var(--bgColor-default, #fff);
62
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
63
+ border-radius: 8px;
64
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
65
+ }
66
+
67
+ .item {
68
+ padding: 6px 10px;
69
+ border-radius: 4px;
70
+ cursor: pointer;
71
+ white-space: nowrap;
72
+ overflow: hidden;
73
+ text-overflow: ellipsis;
74
+ color: var(--fgColor-default, #1f2328);
75
+ }
76
+
77
+ .item:hover {
78
+ background: var(--bgColor-muted, #f6f8fa);
79
+ }
80
+
81
+ .item:focus-visible {
82
+ outline: 2px solid var(--focus-outlineColor, #0969da);
83
+ outline-offset: -2px;
84
+ }
85
+
86
+ .itemActive {
87
+ font-weight: 600;
88
+ background: var(--bgColor-accent-muted, #ddf4ff);
89
+ }
90
+
91
+ .itemActive:hover {
92
+ background: var(--bgColor-accent-muted, #ddf4ff);
93
+ }
@@ -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
+ })
@@ -47,3 +47,7 @@ export function uploadImage(dataUrl, canvasName) {
47
47
  export function toggleImagePrivacy(filename) {
48
48
  return request('/image/toggle-private', 'POST', { filename })
49
49
  }
50
+
51
+ export function getCanvas(name) {
52
+ return request(`/read?name=${encodeURIComponent(name)}`, 'GET')
53
+ }
@@ -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()
@@ -32,12 +32,13 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
32
32
  * fresh widget data from the server to pick up persisted edits.
33
33
  *
34
34
  * @param {string} name - Canvas name as indexed by the data plugin
35
- * @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
35
+ * @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
36
36
  */
37
37
  export function useCanvas(name) {
38
38
  const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
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
@@ -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
 
@@ -109,5 +113,5 @@ export function useCanvas(name) {
109
113
  }
110
114
  }, [name, buildTimeCanvas])
111
115
 
112
- return { canvas, jsxExports, loading }
116
+ return { canvas, jsxExports, jsxError, loading }
113
117
  }