@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.18

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 (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -50,6 +50,11 @@ function resolveFeature(feature) {
50
50
  const r = {}
51
51
  for (const [k, v] of Object.entries(val)) r[k] = resolveVar(v)
52
52
  resolved[key] = r
53
+ } else if (key === 'toggle' && val && typeof val === 'object') {
54
+ // Pass toggle config through as-is (stateKey, activeIcon, activeLabel)
55
+ resolved[key] = { ...val }
56
+ } else if (key === 'surfaces' && Array.isArray(val)) {
57
+ resolved[key] = val
53
58
  } else {
54
59
  resolved[key] = resolveVar(val)
55
60
  }
@@ -115,16 +120,44 @@ export const widgetTypes = buildWidgetTypes()
115
120
  * In production (or when isLocalDev is false, e.g. ?prodMode simulation),
116
121
  * only features with `prod: true` are returned.
117
122
  * In dev, all features are returned.
123
+ *
124
+ * Features with an explicit `surfaces` array that does NOT include `"toolbar"`
125
+ * are excluded — they only render on their declared surfaces (fullbar/splitbar).
126
+ * Features without a `surfaces` array default to toolbar-only.
127
+ *
118
128
  * @param {string} type — widget type string
119
129
  * @param {{ isLocalDev?: boolean }} [options]
120
130
  * @returns {Array} features array from config (variables resolved), or empty array
121
131
  */
122
132
  export function getFeatures(type, { isLocalDev = true } = {}) {
123
133
  const features = widgetTypes[type]?.features ?? []
134
+ let filtered = features.filter(f => {
135
+ const surfaces = f.surfaces || ['toolbar']
136
+ return surfaces.includes('toolbar')
137
+ })
138
+ if (import.meta.env?.PROD || !isLocalDev) {
139
+ filtered = filtered.filter(f => f.prod)
140
+ }
141
+ return filtered
142
+ }
143
+
144
+ /**
145
+ * Get features for a specific rendering surface.
146
+ * Features without a `surfaces` array default to `["toolbar"]`.
147
+ * @param {string} type — widget type string
148
+ * @param {'toolbar' | 'fullbar' | 'splitbar'} surface — target surface
149
+ * @param {{ isLocalDev?: boolean }} [options]
150
+ * @returns {Array} filtered features for the given surface
151
+ */
152
+ export function getFeaturesForSurface(type, surface, { isLocalDev = true } = {}) {
153
+ let features = widgetTypes[type]?.features ?? []
124
154
  if (import.meta.env?.PROD || !isLocalDev) {
125
- return features.filter(f => f.prod)
155
+ features = features.filter(f => f.prod)
126
156
  }
127
- return features
157
+ return features.filter(f => {
158
+ const surfaces = f.surfaces || ['toolbar']
159
+ return surfaces.includes(surface)
160
+ })
128
161
  }
129
162
 
130
163
  /**
@@ -188,7 +221,7 @@ export function getInteractGate(type) {
188
221
  */
189
222
  export function getMenuWidgetTypes() {
190
223
  return Object.entries(widgetTypes)
191
- .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story' && type !== 'terminal-read')
224
+ .filter(([type, def]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story' && type !== 'terminal-read' && !def.unlisted)
192
225
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
193
226
  }
194
227
 
@@ -234,7 +267,7 @@ export function getConnectorDefaults() {
234
267
  endpointFill: defaults.endpointFill ?? 'var(--fgColor-accent, #0969da)',
235
268
  endpointStroke: defaults.endpointStroke ?? 'var(--bgColor-default, #ffffff)',
236
269
  endpointStrokeWidth: defaults.endpointStrokeWidth ?? 3,
237
- hitAreaStrokeWidth: defaults.hitAreaStrokeWidth ?? 16,
270
+ hitAreaStrokeWidth: defaults.hitAreaStrokeWidth ?? 24,
238
271
  dragStroke: defaults.dragStroke ?? 'var(--fgColor-accent, #0969da)',
239
272
  dragStrokeWidth: defaults.dragStrokeWidth ?? 2,
240
273
  dragDasharray: defaults.dragDasharray ?? '6 4',
@@ -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
4
  import { canvases, canvasAliases, 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,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
+ }
package/src/index.js CHANGED
@@ -36,8 +36,10 @@ export { FormContext } from './context/FormContext.js'
36
36
  // Design mode hook (keep — React apps may still read mode state)
37
37
  // ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
38
38
 
39
- // Viewfinder dashboard
40
- 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'
41
43
 
42
44
  // Command Palette (includes BranchBar automatically)
43
45
  export { default as StoryboardCommandPalette } from './CommandPalette/CommandPalette.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
+ }