@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1

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 (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +407 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Shared icon registry for widget toolbars and titlebar actions.
3
+ *
4
+ * Maps string icon names from widgets.config.json to React components.
5
+ * Used by both WidgetChrome (canvas toolbar) and ExpandedPaneTopBar (fullscreen/split titlebars).
6
+ */
7
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold, ScreenFullIcon as OcticonScreenFull, ScreenNormalIcon as OcticonScreenNormal, BroadcastIcon as OcticonBroadcast, CheckIcon as OcticonCheck, MirrorIcon as OcticonMirror } from '@primer/octicons-react'
8
+
9
+ function DeleteIcon() {
10
+ return (
11
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
12
+ <path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.15l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z" />
13
+ </svg>
14
+ )
15
+ }
16
+
17
+ function ZoomInIcon() {
18
+ return (
19
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
20
+ <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
21
+ </svg>
22
+ )
23
+ }
24
+
25
+ function ZoomOutIcon() {
26
+ return (
27
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
28
+ <path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
29
+ </svg>
30
+ )
31
+ }
32
+
33
+ function EditIcon() {
34
+ return (
35
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
36
+ <path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" />
37
+ </svg>
38
+ )
39
+ }
40
+
41
+ function OpenExternalIcon() {
42
+ return (
43
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
44
+ <path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z" />
45
+ </svg>
46
+ )
47
+ }
48
+
49
+ function EyeIcon() {
50
+ return <OcticonEye size={12} />
51
+ }
52
+
53
+ function EyeClosedIcon() {
54
+ return <OcticonEyeClosed size={12} />
55
+ }
56
+
57
+ function CodeIcon() {
58
+ return <OcticonCode size={12} />
59
+ }
60
+
61
+ function UnwrapIcon() {
62
+ return <OcticonUnwrap size={12} />
63
+ }
64
+
65
+ function ImageIcon() {
66
+ return <OcticonImage size={12} />
67
+ }
68
+
69
+ function CopyIcon() {
70
+ return (
71
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
72
+ <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
73
+ <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
74
+ </svg>
75
+ )
76
+ }
77
+
78
+ function MoreIcon() {
79
+ return (
80
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
81
+ <path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
82
+ </svg>
83
+ )
84
+ }
85
+
86
+ function LinkIcon() {
87
+ return (
88
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
89
+ <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z" />
90
+ </svg>
91
+ )
92
+ }
93
+
94
+ function ChevronDownIcon() {
95
+ return (
96
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
97
+ <path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
98
+ </svg>
99
+ )
100
+ }
101
+
102
+ function DownloadIcon() {
103
+ return (
104
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
105
+ <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z" />
106
+ <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z" />
107
+ </svg>
108
+ )
109
+ }
110
+
111
+ function ExpandIcon() {
112
+ return <OcticonScreenFull size={12} />
113
+ }
114
+
115
+ function CollapseIcon() {
116
+ return <OcticonScreenNormal size={12} />
117
+ }
118
+
119
+ function SyncIcon() {
120
+ return (
121
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
122
+ <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
123
+ </svg>
124
+ )
125
+ }
126
+
127
+ function UnfoldIcon() {
128
+ return <OcticonUnfold size={12} />
129
+ }
130
+
131
+ function FoldIcon() {
132
+ return <OcticonFold size={12} />
133
+ }
134
+
135
+ function ColumnsIcon() {
136
+ return (
137
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
138
+ <path d="M12 3v18" /><rect x="3" y="3" width="18" height="18" rx="2" />
139
+ </svg>
140
+ )
141
+ }
142
+
143
+ function CheckMarkIcon() {
144
+ return <OcticonCheck size={12} />
145
+ }
146
+
147
+ function BroadcastIcon() {
148
+ return <OcticonBroadcast size={12} />
149
+ }
150
+
151
+ function MirrorIcon() {
152
+ return <OcticonMirror size={12} />
153
+ }
154
+
155
+ function CropIcon() {
156
+ return (
157
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
158
+ <path d="M3.75 1a.75.75 0 0 1 .75.75V3.5h7a1.75 1.75 0 0 1 1.75 1.75v7h1.75a.75.75 0 0 1 0 1.5H13.25v1.5a.75.75 0 0 1-1.5 0v-1.5h-7A1.75 1.75 0 0 1 3 12.5v-7H1.25a.75.75 0 0 1 0-1.5H3V1.75A.75.75 0 0 1 3.75 1ZM4.5 5.25v7c0 .138.112.25.25.25h7V5.25a.25.25 0 0 0-.25-.25h-7Z" />
159
+ </svg>
160
+ )
161
+ }
162
+
163
+ /** Maps icon name strings from config to React components. */
164
+ export const ICON_REGISTRY = {
165
+ 'trash': DeleteIcon,
166
+ 'zoom-in': ZoomInIcon,
167
+ 'zoom-out': ZoomOutIcon,
168
+ 'edit': EditIcon,
169
+ 'open-external': OpenExternalIcon,
170
+ 'eye': EyeIcon,
171
+ 'eye-closed': EyeClosedIcon,
172
+ 'code': CodeIcon,
173
+ 'unwrap': UnwrapIcon,
174
+ 'image': ImageIcon,
175
+ 'copy': CopyIcon,
176
+ 'link': LinkIcon,
177
+ 'more': MoreIcon,
178
+ 'chevron-down': ChevronDownIcon,
179
+ 'download': DownloadIcon,
180
+ 'expand': ExpandIcon,
181
+ 'collapse': CollapseIcon,
182
+ 'sync': SyncIcon,
183
+ 'unfold': UnfoldIcon,
184
+ 'fold': FoldIcon,
185
+ 'columns': ColumnsIcon,
186
+ 'broadcast': BroadcastIcon,
187
+ 'check': CheckMarkIcon,
188
+ 'crop': CropIcon,
189
+ 'mirror': MirrorIcon,
190
+ }
@@ -130,3 +130,4 @@ export const linkPreviewSchema = schemas['link-preview']
130
130
  export const imageSchema = schemas['image']
131
131
  export const figmaEmbedSchema = schemas['figma-embed']
132
132
  export const terminalSchema = schemas['terminal']
133
+ export const promptSchema = schemas['prompt']
package/src/context.jsx CHANGED
@@ -1,9 +1,10 @@
1
- import { useEffect, useMemo, Suspense, lazy } from 'react'
1
+ import { useState, useEffect, useMemo, Suspense, lazy } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
3
  // Named import seeds the core data index via init() AND provides canvas/story route data
4
- import { canvases, canvasAliases, stories } from 'virtual:storyboard-data-index'
4
+ import { canvases, stories } from 'virtual:storyboard-data-index'
5
5
  import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
+ import usePrototypeReloadGuard from './hooks/usePrototypeReloadGuard.js'
7
8
  import styles from './FlowError.module.css'
8
9
 
9
10
  export { StoryboardContext }
@@ -46,23 +47,35 @@ for (const [, pages] of canvasGroupMap) {
46
47
  }
47
48
  }
48
49
 
49
- // Build a map from story route paths → story names at module load time
50
- const storyRouteMap = new Map()
51
- for (const [name, data] of Object.entries(stories || {})) {
52
- if (data?._route) {
53
- const route = data._route.replace(/\/+$/, '')
54
- storyRouteMap.set(route, name)
55
- }
56
- }
57
-
58
50
  function matchCanvasRoute(pathname) {
59
51
  const normalized = stripBasePath(pathname)
60
52
  return canvasRouteMap.get(normalized) || null
61
53
  }
62
54
 
55
+ /**
56
+ * Live-lookup a story route against the current `stories` object.
57
+ *
58
+ * Unlike the canvas route map (built once at module scope), this iterates
59
+ * the `stories` object on every call so it always reflects HMR mutations
60
+ * (the virtual-module HMR handler mutates `stories` in place).
61
+ *
62
+ * Also strips encoded query strings (%3F / %3f) that can leak into the
63
+ * pathname when an iframe src is percent-encoded incorrectly.
64
+ */
63
65
  function matchStoryRoute(pathname) {
64
- const normalized = stripBasePath(pathname)
65
- return storyRouteMap.get(normalized) || null
66
+ let normalized = stripBasePath(pathname)
67
+ // Strip encoded query strings that leaked into the path (%3F / %3f = ?)
68
+ const encodedIdx = normalized.search(/%3f/i)
69
+ if (encodedIdx !== -1) normalized = normalized.substring(0, encodedIdx)
70
+ const literalIdx = normalized.indexOf('?')
71
+ if (literalIdx !== -1) normalized = normalized.substring(0, literalIdx)
72
+
73
+ for (const [name, data] of Object.entries(stories || {})) {
74
+ if (data?._route && data._route.replace(/\/+$/, '') === normalized) {
75
+ return name
76
+ }
77
+ }
78
+ return null
66
79
  }
67
80
 
68
81
  /**
@@ -133,6 +146,9 @@ function getPageFlowName(pathname) {
133
146
  export default function StoryboardProvider({ flowName, sceneName, recordName, recordParam, children }) {
134
147
  const basePath = import.meta.env?.BASE_URL || '/'
135
148
 
149
+ // Suppress HMR full-reloads when prototype-auto-reload flag is off
150
+ usePrototypeReloadGuard()
151
+
136
152
  return (
137
153
  <>
138
154
  <StoryboardProviderInner
@@ -154,20 +170,32 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
154
170
  const location = useLocation()
155
171
  const params = useParams()
156
172
 
157
- // Canvas route detection matches current URL against registered canvas routes
158
- const canvasId = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
159
- const isMissingCanvasRoute = useMemo(
160
- () => isCanvasPath(location.pathname) && !canvasId && !matchStoryRoute(location.pathname),
161
- [location.pathname, canvasId],
162
- )
173
+ // Re-evaluate story route detection when the data index changes via HMR.
174
+ // The virtual-module HMR handler mutates `stories` in place and dispatches
175
+ // this event; bumping the key forces useMemo deps to re-fire.
176
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
177
+ useEffect(() => {
178
+ const handler = () => setStoryIndexKey((k) => k + 1)
179
+ document.addEventListener('storyboard:story-index-changed', handler)
180
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
181
+ }, [])
163
182
 
164
183
  // Story route detection — matches current URL against registered story routes
165
- const storyName = useMemo(() => matchStoryRoute(location.pathname), [location.pathname])
184
+ // storyIndexKey forces re-evaluation when HMR mutates the stories object in place
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
+ const storyName = useMemo(() => matchStoryRoute(location.pathname), [location.pathname, storyIndexKey])
166
187
  const isMissingStoryRoute = useMemo(
167
188
  () => isStoryPath(location.pathname) && !storyName,
168
189
  [location.pathname, storyName],
169
190
  )
170
191
 
192
+ // Canvas route detection — matches current URL against registered canvas routes
193
+ const canvasId = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
194
+ const isMissingCanvasRoute = useMemo(
195
+ () => isCanvasPath(location.pathname) && !canvasId && !storyName,
196
+ [location.pathname, canvasId, storyName],
197
+ )
198
+
171
199
  const searchParams = new URLSearchParams(location.search)
172
200
  const sceneParam = searchParams.get('flow') || searchParams.get('scene')
173
201
  const prototypeName = getPrototypeName(location.pathname)
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore, useCallback } from 'react'
2
+ import { getConfig, subscribeToConfig, getConfigSnapshot } from '@dfosco/storyboard-core'
3
+
4
+ /**
5
+ * React hook for reading from the unified config store.
6
+ *
7
+ * @param {string} [domain] - Optional domain key (e.g. 'toolbar', 'canvas')
8
+ * @returns {object} The config object (full or domain slice)
9
+ */
10
+ export function useConfig(domain) {
11
+ const snapshot = useSyncExternalStore(subscribeToConfig, getConfigSnapshot)
12
+ // eslint-disable-next-line react-hooks/exhaustive-deps
13
+ return useCallback(() => getConfig(domain), [snapshot, domain])()
14
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Prototype reload guard — suppresses Vite HMR full-reloads for non-canvas pages.
3
+ *
4
+ * Controlled by the "prototype-auto-reload" feature flag (default: true).
5
+ * When the flag is false (user opted out), sends heartbeat messages to the
6
+ * Vite dev server which suppresses full-reload and update payloads for this
7
+ * client. Custom storyboard events (canvas file changes, story changes, etc.)
8
+ * always pass through.
9
+ *
10
+ * Heartbeats are sent every 3s and auto-expire server-side after 5s, so
11
+ * closed tabs never leave the guard stuck.
12
+ */
13
+ import { useEffect } from 'react'
14
+ import { getFlag, subscribeToStorage } from '@dfosco/storyboard-core'
15
+
16
+ const FLAG_KEY = 'prototype-auto-reload'
17
+ const HEARTBEAT_MS = 3000
18
+
19
+ export default function usePrototypeReloadGuard() {
20
+ useEffect(() => {
21
+ if (!import.meta.hot) return
22
+
23
+ let interval = null
24
+
25
+ function start() {
26
+ if (interval) return
27
+ const msg = { active: true }
28
+ import.meta.hot.send('storyboard:prototype-reload-guard', msg)
29
+ interval = setInterval(() => {
30
+ import.meta.hot.send('storyboard:prototype-reload-guard', msg)
31
+ }, HEARTBEAT_MS)
32
+ }
33
+
34
+ function stop() {
35
+ if (interval) {
36
+ clearInterval(interval)
37
+ interval = null
38
+ }
39
+ import.meta.hot.send('storyboard:prototype-reload-guard', { active: false })
40
+ }
41
+
42
+ function sync() {
43
+ const autoReload = getFlag(FLAG_KEY)
44
+ if (autoReload) {
45
+ stop()
46
+ } else {
47
+ start()
48
+ }
49
+ }
50
+
51
+ // Initial sync
52
+ sync()
53
+
54
+ // Re-sync when the flag changes in localStorage (e.g. toggled from devtools)
55
+ const unsub = subscribeToStorage((key) => {
56
+ if (key === 'flag.' + FLAG_KEY) sync()
57
+ })
58
+
59
+ return () => {
60
+ stop()
61
+ unsub()
62
+ }
63
+ }, [])
64
+ }
@@ -36,6 +36,7 @@ export function useFlowData(path, opts) {
36
36
  const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
37
37
 
38
38
  // Collect overrides relevant to this path
39
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization
39
40
  const result = useMemo(() => {
40
41
  if (loading || error || data == null) return undefined
41
42
 
@@ -1,5 +1,5 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
- import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { describe, it, expect, beforeEach } from 'vitest'
3
3
  import { setTheme, setThemeSyncTarget } from '@dfosco/storyboard-core'
4
4
  import { useThemeState, useThemeSyncTargets } from './useThemeState.js'
5
5
 
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ export { useUndoRedo } from './hooks/useUndoRedo.js'
25
25
  export { useFeatureFlag } from './hooks/useFeatureFlag.js'
26
26
  export { useMode } from './hooks/useMode.js'
27
27
  export { useThemeState, useThemeSyncTargets } from './hooks/useThemeState.js'
28
+ export { useConfig } from './hooks/useConfig.js'
28
29
 
29
30
  // React Router integration
30
31
  export { installHashPreserver } from './hashPreserver.js'
@@ -35,8 +36,10 @@ export { FormContext } from './context/FormContext.js'
35
36
  // Design mode hook (keep — React apps may still read mode state)
36
37
  // ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
37
38
 
38
- // Viewfinder dashboard
39
- export { default as Viewfinder } from './Viewfinder.jsx'
39
+ // Workspace dashboard
40
+ export { default as Workspace } from './Workspace.jsx'
41
+ // Deprecated alias — use Workspace instead
42
+ export { default as Viewfinder } from './Workspace.jsx'
40
43
 
41
44
  // Command Palette (includes BranchBar automatically)
42
45
  export { default as StoryboardCommandPalette } from './CommandPalette/CommandPalette.jsx'
@@ -50,3 +53,6 @@ export { default as AuthModal } from './AuthModal/AuthModal.jsx'
50
53
  // Canvas
51
54
  export { default as CanvasPage } from './canvas/CanvasPage.jsx'
52
55
  export { useCanvas } from './canvas/useCanvas.js'
56
+
57
+ // Icon
58
+ export { default as Icon } from './Icon.jsx'
@@ -0,0 +1,186 @@
1
+ /**
2
+ * ComponentSetPage — renders all exports from a .story.jsx in a grid.
3
+ *
4
+ * Mounted when StoryPage detects `?_sb_component_set` in the URL.
5
+ * Each export gets its own grid cell with a selectable label header.
6
+ *
7
+ * URL params:
8
+ * layout — "horizontal" (default) | "vertical"
9
+ * selected — export name of the currently selected cell
10
+ *
11
+ * Selection: clicking a cell label updates `?selected=` and posts
12
+ * `storyboard:component-set:select` to the parent window so the
13
+ * canvas widget can track which export the user picked.
14
+ */
15
+ import { useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef } from 'react'
16
+ import { useLocation, useNavigate } from 'react-router-dom'
17
+ import { getStoryData } from '@dfosco/storyboard-core'
18
+ import { ThemeProvider, BaseStyles } from '@primer/react'
19
+ import styles from './ComponentSetPage.module.css'
20
+
21
+ export default function ComponentSetPage({ name }) {
22
+ const location = useLocation()
23
+ const navigate = useNavigate()
24
+ const searchParams = new URLSearchParams(location.search)
25
+
26
+ const layout = searchParams.get('layout') || 'horizontal'
27
+ const selected = searchParams.get('selected') || ''
28
+ const isEmbed = searchParams.has('_sb_embed')
29
+
30
+ // Suppress HMR full-reloads when embedded as a canvas iframe
31
+ useEffect(() => {
32
+ if (!isEmbed || !import.meta.hot) return
33
+ const msg = { active: true }
34
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
35
+ const interval = setInterval(() => {
36
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
37
+ }, 3000)
38
+ return () => {
39
+ clearInterval(interval)
40
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
41
+ }
42
+ }, [isEmbed])
43
+
44
+ const story = useMemo(() => getStoryData(name), [name])
45
+ const [exports, setExports] = useState(null)
46
+ const [error, setError] = useState(null)
47
+
48
+ useEffect(() => {
49
+ if (!story?._storyImport) {
50
+ Promise.resolve().then(() => setError(`Story "${name}" not found or missing import`))
51
+ return
52
+ }
53
+
54
+ let cancelled = false
55
+ story._storyImport()
56
+ .then((mod) => {
57
+ if (cancelled) return
58
+ const namedExports = {}
59
+ for (const [key, value] of Object.entries(mod)) {
60
+ if (key !== 'default' && typeof value === 'function') {
61
+ namedExports[key] = value
62
+ }
63
+ }
64
+ setExports(namedExports)
65
+ setError(null)
66
+ })
67
+ .catch((err) => {
68
+ if (cancelled) return
69
+ setError(`Failed to load story "${name}": ${err.message || err}`)
70
+ })
71
+
72
+ return () => { cancelled = true }
73
+ }, [name, story])
74
+
75
+ // Signal snapshot-ready after grid renders in embed mode
76
+ useEffect(() => {
77
+ if (!isEmbed || !exports || window.parent === window) return
78
+ document.fonts.ready.then(() => {
79
+ requestAnimationFrame(() => requestAnimationFrame(() => {
80
+ window.__sbSnapshotReady?.()
81
+ }))
82
+ })
83
+ }, [isEmbed, exports])
84
+
85
+ const gridRef = useRef(null)
86
+
87
+ // Measure all cell content elements and snap cells to the largest
88
+ useLayoutEffect(() => {
89
+ const grid = gridRef.current
90
+ if (!grid || !exports) return
91
+
92
+ const cells = grid.querySelectorAll('[data-cell-content]')
93
+ if (cells.length === 0) return
94
+
95
+ function measure() {
96
+ let maxW = 0
97
+ let maxH = 0
98
+ for (const el of cells) {
99
+ maxW = Math.max(maxW, el.scrollWidth)
100
+ maxH = Math.max(maxH, el.scrollHeight)
101
+ }
102
+ grid.style.setProperty('--cell-snap-w', `${maxW}px`)
103
+ grid.style.setProperty('--cell-snap-h', `${maxH}px`)
104
+ }
105
+
106
+ // Measure after fonts load and initial paint
107
+ measure()
108
+ document.fonts.ready.then(() => requestAnimationFrame(measure))
109
+
110
+ const ro = new ResizeObserver(measure)
111
+ for (const el of cells) ro.observe(el)
112
+ return () => ro.disconnect()
113
+ }, [exports, layout])
114
+
115
+ const handleSelect = useCallback((exportName) => {
116
+ const params = new URLSearchParams(location.search)
117
+ if (exportName === params.get('selected')) {
118
+ params.delete('selected')
119
+ } else {
120
+ params.set('selected', exportName)
121
+ }
122
+ navigate(`${location.pathname}?${params}`, { replace: true })
123
+
124
+ // Notify parent widget
125
+ if (window.parent !== window) {
126
+ window.parent.postMessage({
127
+ type: 'storyboard:component-set:select',
128
+ storyId: name,
129
+ exportName: exportName === selected ? null : exportName,
130
+ }, '*')
131
+ }
132
+ }, [location, navigate, name, selected])
133
+
134
+ if (error) {
135
+ return (
136
+ <div className={styles.error}>
137
+ <strong>Component Set Error</strong>
138
+ <span>{error}</span>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ if (!exports) {
144
+ if (isEmbed) return null
145
+ return <div className={styles.loading}>Loading component set…</div>
146
+ }
147
+
148
+ const exportNames = Object.keys(exports)
149
+
150
+ return (
151
+ <ThemeProvider colorMode="day">
152
+ <BaseStyles>
153
+ <div
154
+ ref={gridRef}
155
+ className={styles.grid}
156
+ data-layout={layout}
157
+ >
158
+ {exportNames.map((exportName) => {
159
+ const Component = exports[exportName]
160
+ const isSelected = exportName === selected
161
+ return (
162
+ <div
163
+ key={exportName}
164
+ className={styles.cell}
165
+ data-selected={isSelected || undefined}
166
+ >
167
+ <button
168
+ className={styles.cellLabel}
169
+ onClick={() => handleSelect(exportName)}
170
+ data-selected={isSelected || undefined}
171
+ aria-pressed={isSelected}
172
+ >
173
+ <span className={styles.cellRadio} data-selected={isSelected || undefined} />
174
+ <span className={styles.cellName}>{exportName}</span>
175
+ </button>
176
+ <div className={styles.cellContent} data-cell-content>
177
+ <Component />
178
+ </div>
179
+ </div>
180
+ )
181
+ })}
182
+ </div>
183
+ </BaseStyles>
184
+ </ThemeProvider>
185
+ )
186
+ }