@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.31

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 +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -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 +458 -71
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -32,6 +32,13 @@
32
32
  min-height: 100%;
33
33
  }
34
34
 
35
+ /* GPU compositing hint during active zoom gestures — applied imperatively
36
+ via data-zooming attribute and removed on zoom-end to avoid permanent
37
+ memory pressure on the large canvas surface. */
38
+ .canvasZoom[data-zooming] {
39
+ will-change: transform;
40
+ }
41
+
35
42
  /* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
36
43
 
37
44
  .canvasTitle {
@@ -44,58 +51,14 @@
44
51
  gap: 8px;
45
52
  }
46
53
 
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 {
54
+ .canvasTitleStatic {
68
55
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
69
56
  font-size: 14px;
70
57
  font-weight: 600;
71
58
  color: var(--fgColor-muted, #656d76);
72
- background: transparent;
73
- border: 1px solid transparent;
74
- border-radius: 6px;
75
- padding: 4px 8px;
76
59
  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;
60
+ padding: 4px 8px;
61
+ white-space: nowrap;
99
62
  }
100
63
 
101
64
  /* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
@@ -103,6 +66,12 @@
103
66
  overflow: visible;
104
67
  }
105
68
 
69
+ /* Elevate stacking context for hovered/selected widgets so their chrome
70
+ (toolbar, menus, selection outline) renders above sibling widgets. */
71
+ :global(.tc-drag:has([data-tc-elevated])) {
72
+ z-index: 1;
73
+ }
74
+
106
75
  .localEditingLabel {
107
76
  display: inline-flex;
108
77
  align-items: center;
@@ -117,3 +86,57 @@
117
86
  pointer-events: none;
118
87
  user-select: none;
119
88
  }
89
+
90
+ .ghInstallBanner {
91
+ position: fixed;
92
+ left: 50%;
93
+ bottom: 12px;
94
+ transform: translateX(-50%);
95
+ z-index: 15;
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 10px;
99
+ padding: 8px 12px;
100
+ border-radius: 8px;
101
+ border: 1px solid var(--borderColor-default, #d1d9e0);
102
+ background: color-mix(in srgb, var(--bgColor-default, #ffffff) 88%, transparent);
103
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
104
+ backdrop-filter: blur(8px);
105
+ font-size: 12px;
106
+ color: var(--fgColor-default, #1f2328);
107
+ }
108
+
109
+ .ghInstallBannerText {
110
+ white-space: nowrap;
111
+ }
112
+
113
+ .ghInstallBannerText code {
114
+ font-size: 11px;
115
+ padding: 1px 4px;
116
+ border-radius: 4px;
117
+ background: var(--bgColor-muted, #f6f8fa);
118
+ }
119
+
120
+ .ghInstallBannerLink {
121
+ color: var(--fgColor-accent, #0969da);
122
+ font-weight: 600;
123
+ text-decoration: none;
124
+ }
125
+
126
+ .ghInstallBannerLink:hover {
127
+ text-decoration: underline;
128
+ }
129
+
130
+ .ghInstallBannerDismiss {
131
+ border: 1px solid var(--borderColor-default, #d1d9e0);
132
+ background: var(--bgColor-default, #ffffff);
133
+ color: var(--fgColor-default, #1f2328);
134
+ border-radius: 6px;
135
+ font-size: 11px;
136
+ padding: 4px 8px;
137
+ cursor: pointer;
138
+ }
139
+
140
+ .ghInstallBannerDismiss:hover {
141
+ background: var(--bgColor-muted, #f6f8fa);
142
+ }
@@ -103,6 +103,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
103
103
 
104
104
  vi.mock('./canvasApi.js', () => ({
105
105
  addWidget: vi.fn(),
106
+ checkGitHubCliAvailable: vi.fn(() => Promise.resolve({ available: true })),
107
+ fetchGitHubEmbed: vi.fn(() => Promise.resolve({ success: false })),
106
108
  updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
107
109
  removeWidget: vi.fn(() => Promise.resolve({ success: true })),
108
110
  uploadImage: vi.fn(),
@@ -123,7 +125,7 @@ describe('CanvasPage multi-select', () => {
123
125
  })
124
126
 
125
127
  it('shift+click on select handle adds widget to selection', async () => {
126
- render(<CanvasPage name="test-canvas" />)
128
+ render(<CanvasPage canvasId="test-canvas" />)
127
129
 
128
130
  // Select first widget
129
131
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -138,7 +140,7 @@ describe('CanvasPage multi-select', () => {
138
140
  })
139
141
 
140
142
  it('shift+click on already selected widget removes it from selection', async () => {
141
- render(<CanvasPage name="test-canvas" />)
143
+ render(<CanvasPage canvasId="test-canvas" />)
142
144
 
143
145
  // Select both
144
146
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -155,7 +157,7 @@ describe('CanvasPage multi-select', () => {
155
157
  })
156
158
 
157
159
  it('normal click replaces multi-selection with single', async () => {
158
- render(<CanvasPage name="test-canvas" />)
160
+ render(<CanvasPage canvasId="test-canvas" />)
159
161
 
160
162
  // Multi-select
161
163
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -172,7 +174,7 @@ describe('CanvasPage multi-select', () => {
172
174
  })
173
175
 
174
176
  it('sets multiSelected on all selected widgets when multiple are selected', async () => {
175
- render(<CanvasPage name="test-canvas" />)
177
+ render(<CanvasPage canvasId="test-canvas" />)
176
178
 
177
179
  fireEvent.click(screen.getByTestId('select-w1'))
178
180
  fireEvent.click(screen.getByTestId('shift-select-w2'))
@@ -185,7 +187,7 @@ describe('CanvasPage multi-select', () => {
185
187
  })
186
188
 
187
189
  it('Escape clears all selection', async () => {
188
- render(<CanvasPage name="test-canvas" />)
190
+ render(<CanvasPage canvasId="test-canvas" />)
189
191
 
190
192
  fireEvent.click(screen.getByTestId('select-w1'))
191
193
  fireEvent.click(screen.getByTestId('shift-select-w2'))
@@ -199,7 +201,7 @@ describe('CanvasPage multi-select', () => {
199
201
  })
200
202
 
201
203
  it('Delete removes all selected widgets and calls updateCanvas', async () => {
202
- render(<CanvasPage name="test-canvas" />)
204
+ render(<CanvasPage canvasId="test-canvas" />)
203
205
 
204
206
  // Multi-select w1 and w2
205
207
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -224,7 +226,7 @@ describe('CanvasPage multi-select', () => {
224
226
  })
225
227
 
226
228
  it('single-select Delete uses removeWidget API', async () => {
227
- render(<CanvasPage name="test-canvas" />)
229
+ render(<CanvasPage canvasId="test-canvas" />)
228
230
 
229
231
  fireEvent.click(screen.getByTestId('select-w1'))
230
232
  fireEvent.keyDown(document, { key: 'Backspace' })
@@ -234,7 +236,7 @@ describe('CanvasPage multi-select', () => {
234
236
  })
235
237
 
236
238
  it('multi-select move applies delta to all selected widgets', async () => {
237
- render(<CanvasPage name="test-canvas" />)
239
+ render(<CanvasPage canvasId="test-canvas" />)
238
240
 
239
241
  // Multi-select w1 (100,100) and w2 (300,100)
240
242
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -265,7 +267,7 @@ describe('CanvasPage multi-select', () => {
265
267
  })
266
268
 
267
269
  it('multi-select drag captures peer articles on drag start', async () => {
268
- render(<CanvasPage name="test-canvas" />)
270
+ render(<CanvasPage canvasId="test-canvas" />)
269
271
 
270
272
  // Multi-select w1 and w2
271
273
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -288,7 +290,7 @@ describe('CanvasPage multi-select', () => {
288
290
  })
289
291
 
290
292
  it('multi-select drag preserves selection after drag end', async () => {
291
- render(<CanvasPage name="test-canvas" />)
293
+ render(<CanvasPage canvasId="test-canvas" />)
292
294
 
293
295
  // Multi-select w1 and w2
294
296
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -313,7 +315,7 @@ describe('CanvasPage multi-select', () => {
313
315
  })
314
316
 
315
317
  it('any selected widget can serve as drag handler for the group', async () => {
316
- render(<CanvasPage name="test-canvas" />)
318
+ render(<CanvasPage canvasId="test-canvas" />)
317
319
 
318
320
  // Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
319
321
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -9,7 +9,7 @@ const WIDGET_TYPES = getMenuWidgetTypes()
9
9
  /**
10
10
  * Floating toolbar for adding widgets to a canvas.
11
11
  */
12
- export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
12
+ export default function CanvasToolbar({ canvasId, onWidgetAdded }) {
13
13
  const [open, setOpen] = useState(false)
14
14
  const [adding, setAdding] = useState(false)
15
15
 
@@ -18,7 +18,7 @@ export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
18
18
  setAdding(true)
19
19
  try {
20
20
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
21
- const result = await addWidgetApi(canvasName, {
21
+ const result = await addWidgetApi(canvasId, {
22
22
  type,
23
23
  props: defaultProps,
24
24
  position: { x: 0, y: 0 },
@@ -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
+ })
@@ -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
+ }