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

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
package/src/context.jsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, Suspense, lazy } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
- // Named import seeds the core data index via init() AND provides canvas route data
4
- import { canvases } from 'virtual:storyboard-data-index'
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'
5
5
  import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
  import styles from './FlowError.module.css'
@@ -9,24 +9,77 @@ import styles from './FlowError.module.css'
9
9
  export { StoryboardContext }
10
10
 
11
11
  const CanvasPageLazy = lazy(() => import('./canvas/CanvasPage.jsx'))
12
+ const StoryPageLazy = lazy(() => import('./story/StoryPage.jsx'))
12
13
 
13
14
  // Build a map from canvas route paths → canvas names at module load time
14
15
  const canvasRouteMap = new Map()
16
+ // Build a map from group name → array of { name, route, title } for page selector
17
+ const canvasGroupMap = new Map()
15
18
  for (const [name, data] of Object.entries(canvases || {})) {
16
- const route = (data?._route || `/${name}`).replace(/\/+$/, '')
19
+ const route = (data?._route || `/canvas/${name}`).replace(/\/+$/, '')
17
20
  canvasRouteMap.set(route, name)
21
+ const group = data?._group
22
+ if (group) {
23
+ if (!canvasGroupMap.has(group)) canvasGroupMap.set(group, [])
24
+ canvasGroupMap.get(group).push({
25
+ name,
26
+ route,
27
+ title: data?.title || name.split('/').pop(),
28
+ _canvasMeta: data?._canvasMeta || null,
29
+ })
30
+ }
31
+ }
32
+
33
+ // Build a map from story route paths → story names at module load time
34
+ const storyRouteMap = new Map()
35
+ for (const [name, data] of Object.entries(stories || {})) {
36
+ if (data?._route) {
37
+ const route = data._route.replace(/\/+$/, '')
38
+ storyRouteMap.set(route, name)
39
+ }
18
40
  }
19
41
 
20
42
  function matchCanvasRoute(pathname) {
21
- const normalized = pathname.replace(/\/+$/, '') || '/'
43
+ const normalized = stripBasePath(pathname)
22
44
  return canvasRouteMap.get(normalized) || null
23
45
  }
24
46
 
47
+ function matchStoryRoute(pathname) {
48
+ const normalized = stripBasePath(pathname)
49
+ return storyRouteMap.get(normalized) || null
50
+ }
51
+
52
+ /**
53
+ * Strip the app's sub-path prefix (e.g. /storyboard) from the pathname.
54
+ * React Router's basename strips the branch prefix but not the app name prefix
55
+ * when the app runs under a nested base path.
56
+ */
57
+ function stripBasePath(pathname) {
58
+ let p = pathname.replace(/\/+$/, '') || '/'
59
+ // BASE_URL includes branch prefix + app path (e.g. /branch--name/storyboard/)
60
+ // React Router strips the branch prefix but may leave the app sub-path
61
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/+$/, '')
62
+ if (base && base !== '/') {
63
+ // Extract just the last segment(s) after the branch prefix
64
+ const withoutBranch = base.replace(/^\/branch--[^/]+/, '')
65
+ const subPath = withoutBranch.replace(/\/+$/, '')
66
+ if (subPath && p.startsWith(subPath)) {
67
+ p = p.slice(subPath.length) || '/'
68
+ }
69
+ }
70
+ return p
71
+ }
72
+
25
73
  function isCanvasPath(pathname) {
26
- const normalized = pathname.replace(/\/+$/, '') || '/'
74
+ const normalized = stripBasePath(pathname)
27
75
  return normalized === '/canvas' || normalized.startsWith('/canvas/')
28
76
  }
29
77
 
78
+ function isStoryPath(pathname) {
79
+ const normalized = stripBasePath(pathname)
80
+ return normalized === '/components' || normalized.startsWith('/components/')
81
+ }
82
+
30
83
  /**
31
84
  * Derives the top-level prototype name from a pathname.
32
85
  * "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
@@ -68,18 +121,25 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
68
121
  // Canvas route detection — matches current URL against registered canvas routes
69
122
  const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
70
123
  const isMissingCanvasRoute = useMemo(
71
- () => isCanvasPath(location.pathname) && !canvasName,
124
+ () => isCanvasPath(location.pathname) && !canvasName && !matchStoryRoute(location.pathname),
72
125
  [location.pathname, canvasName],
73
126
  )
74
127
 
128
+ // Story route detection — matches current URL against registered story routes
129
+ const storyName = useMemo(() => matchStoryRoute(location.pathname), [location.pathname])
130
+ const isMissingStoryRoute = useMemo(
131
+ () => isStoryPath(location.pathname) && !storyName,
132
+ [location.pathname, storyName],
133
+ )
134
+
75
135
  const searchParams = new URLSearchParams(location.search)
76
136
  const sceneParam = searchParams.get('flow') || searchParams.get('scene')
77
137
  const prototypeName = getPrototypeName(location.pathname)
78
138
  const pageFlow = getPageFlowName(location.pathname)
79
139
 
80
- // Resolve flow name with prototype scoping (skip for canvas pages)
140
+ // Resolve flow name with prototype scoping (skip for canvas/story pages)
81
141
  const activeFlowName = useMemo(() => {
82
- if (canvasName || isMissingCanvasRoute) return null
142
+ if (canvasName || isMissingCanvasRoute || storyName || isMissingStoryRoute) return null
83
143
  const requested = sceneParam || flowName || sceneName
84
144
  if (requested) {
85
145
  // Allow fully-scoped flow names from URLs/widgets without re-prefixing
@@ -103,11 +163,32 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
103
163
  // 4. Global default — or null if no flow exists at all
104
164
  if (flowExists('default')) return 'default'
105
165
  return null
106
- }, [canvasName, isMissingCanvasRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
166
+ }, [canvasName, isMissingCanvasRoute, storyName, isMissingStoryRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
107
167
 
108
168
  // Auto-install body class sync (sb-key--value classes on <body>)
109
169
  useEffect(() => installBodyClassSync(), [])
110
170
 
171
+ // Update document.title to reflect the current artifact
172
+ useEffect(() => {
173
+ const base = import.meta.env?.BASE_URL || '/'
174
+ const branchMatch = base.match(/\/branch--([^/]+)/)
175
+ const branchSuffix = branchMatch ? ` (${branchMatch[1]})` : ''
176
+
177
+ let title
178
+ if (canvasName) {
179
+ const canvasData = canvases?.[canvasName]
180
+ const meta = canvasData?._canvasMeta
181
+ const pageTitle = canvasData?.title || canvasName.split('/').pop()
182
+ title = (meta?.title || pageTitle) + ' · Storyboard'
183
+ } else if (prototypeName) {
184
+ title = prototypeName + ' · Storyboard'
185
+ } else {
186
+ title = 'Storyboard'
187
+ }
188
+
189
+ document.title = title + branchSuffix
190
+ }, [canvasName, prototypeName])
191
+
111
192
  // Mount design modes UI when enabled in storyboard.config.json
112
193
  useEffect(() => {
113
194
  if (!isModesEnabled()) return
@@ -124,9 +205,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
124
205
  return () => cleanup?.()
125
206
  }, [])
126
207
 
127
- // Skip flow loading for canvas pages and flow-less pages
208
+ // Skip flow loading for canvas/story pages and flow-less pages
128
209
  const { data, error } = useMemo(() => {
129
- if (canvasName || isMissingCanvasRoute) return { data: null, error: null }
210
+ if (canvasName || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
130
211
  if (!activeFlowName) return { data: {}, error: null }
131
212
  try {
132
213
  let flowData = loadFlow(activeFlowName)
@@ -145,10 +226,14 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
145
226
  } catch (err) {
146
227
  return { data: null, error: err.message }
147
228
  }
148
- }, [canvasName, isMissingCanvasRoute, activeFlowName, recordName, recordParam, params, prototypeName])
229
+ }, [canvasName, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
149
230
 
150
231
  // Canvas pages get their own rendering path — no flow data needed
151
232
  if (canvasName) {
233
+ const canvasData = canvases?.[canvasName]
234
+ const group = canvasData?._group
235
+ const siblingPages = group ? canvasGroupMap.get(group) || [] : []
236
+ const canvasMeta = canvasData?._canvasMeta || null
152
237
  const canvasValue = {
153
238
  data: null,
154
239
  error: null,
@@ -160,7 +245,26 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
160
245
  return (
161
246
  <StoryboardContext.Provider value={canvasValue}>
162
247
  <Suspense fallback={null}>
163
- <CanvasPageLazy name={canvasName} />
248
+ <CanvasPageLazy name={canvasName} siblingPages={siblingPages} canvasMeta={canvasMeta} />
249
+ </Suspense>
250
+ </StoryboardContext.Provider>
251
+ )
252
+ }
253
+
254
+ // Story pages get their own rendering path — no flow data needed
255
+ if (storyName) {
256
+ const storyValue = {
257
+ data: null,
258
+ error: null,
259
+ loading: false,
260
+ flowName: null,
261
+ sceneName: null,
262
+ prototypeName: null,
263
+ }
264
+ return (
265
+ <StoryboardContext.Provider value={storyValue}>
266
+ <Suspense fallback={null}>
267
+ <StoryPageLazy name={storyName} />
164
268
  </Suspense>
165
269
  </StoryboardContext.Provider>
166
270
  )
@@ -187,6 +291,27 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
187
291
  )
188
292
  }
189
293
 
294
+ if (isMissingStoryRoute) {
295
+ const currentUrl = `${location.pathname}${location.search}`
296
+ const truncatedUrl = currentUrl.length > 60
297
+ ? currentUrl.slice(0, 60) + '…'
298
+ : currentUrl
299
+
300
+ return (
301
+ <main className={styles.container}>
302
+ <div className={styles.banner}>
303
+ <strong>Story not found</strong>
304
+ No story matches this route.
305
+ </div>
306
+ <p className={styles.meta}>
307
+ Tried to open{' '}
308
+ <a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
309
+ </p>
310
+ <a className={styles.homeLink} href="/">← Go to index page</a>
311
+ </main>
312
+ )
313
+ }
314
+
190
315
  const value = {
191
316
  data,
192
317
  error,
@@ -17,10 +17,12 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
19
  * Omit to get the entire flow object.
20
+ * @param {{ optional?: boolean }} [opts] - Pass { optional: true } to suppress
21
+ * the "path not found" warning for optional data.
20
22
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
23
  * @throws If used outside a StoryboardProvider.
22
24
  */
23
- export function useFlowData(path) {
25
+ export function useFlowData(path, opts) {
24
26
  const context = useContext(StoryboardContext)
25
27
 
26
28
  if (context === null) {
@@ -73,7 +75,7 @@ export function useFlowData(path) {
73
75
  }
74
76
 
75
77
  if (sceneValue === undefined) {
76
- if (data != null && Object.keys(data).length > 0) {
78
+ if (!opts?.optional && data != null && Object.keys(data).length > 0) {
77
79
  console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
78
80
  }
79
81
  return {}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * StoryPage — renders a .story.jsx module at its own route.
3
+ *
4
+ * When visited at e.g. /canvas/button-patterns, renders all named exports
5
+ * from button-patterns.story.jsx in a gallery layout.
6
+ *
7
+ * When ?export=ExportName is present, renders only that single export
8
+ * (used by iframe embeds from canvas StoryWidget).
9
+ */
10
+ import { useState, useEffect, useMemo } from 'react'
11
+ import { useLocation } from 'react-router-dom'
12
+ import { getStoryData } from '@dfosco/storyboard-core'
13
+ import { ThemeProvider, BaseStyles } from '@primer/react'
14
+ import styles from './StoryPage.module.css'
15
+
16
+ function StoryErrorBoundaryFallback({ name, error }) {
17
+ return (
18
+ <div className={styles.error}>
19
+ <strong>{name}</strong>
20
+ <span>{String(error?.message || error)}</span>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export default function StoryPage({ name }) {
26
+ const location = useLocation()
27
+ const searchParams = new URLSearchParams(location.search)
28
+ const exportFilter = searchParams.get('export')
29
+ const isEmbed = searchParams.has('_sb_embed')
30
+
31
+ const story = useMemo(() => getStoryData(name), [name])
32
+ const [exports, setExports] = useState(null)
33
+ const [error, setError] = useState(null)
34
+
35
+ useEffect(() => {
36
+ if (!story?._storyImport) {
37
+ Promise.resolve().then(() => setError(`Story "${name}" not found or missing import`))
38
+ return
39
+ }
40
+
41
+ let cancelled = false
42
+ story._storyImport()
43
+ .then((mod) => {
44
+ if (cancelled) return
45
+ const namedExports = {}
46
+ for (const [key, value] of Object.entries(mod)) {
47
+ if (key !== 'default' && typeof value === 'function') {
48
+ namedExports[key] = value
49
+ }
50
+ }
51
+ setExports(namedExports)
52
+ setError(null)
53
+ })
54
+ .catch((err) => {
55
+ if (cancelled) return
56
+ setError(`Failed to load story "${name}": ${err.message || err}`)
57
+ })
58
+
59
+ return () => { cancelled = true }
60
+ }, [name, story])
61
+
62
+ // Signal snapshot-ready after story renders in embed mode
63
+ useEffect(() => {
64
+ if (!isEmbed || !exports || window.parent === window) return
65
+ // Wait for fonts + paint to settle before signaling ready
66
+ Promise.all([
67
+ document.fonts?.ready || Promise.resolve(),
68
+ new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))),
69
+ ]).then(() => {
70
+ window.parent.postMessage({ type: 'storyboard:embed:snapshot-ready' }, '*')
71
+ })
72
+ }, [isEmbed, exports])
73
+
74
+ if (error) {
75
+ return (
76
+ <div className={styles.page}>
77
+ <StoryErrorBoundaryFallback name={name} error={error} />
78
+ </div>
79
+ )
80
+ }
81
+
82
+ if (!exports) {
83
+ return (
84
+ <div className={styles.page}>
85
+ <div className={styles.loading}>Loading story…</div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ // Single export mode (for iframe embedding)
91
+ if (exportFilter) {
92
+ const Component = exports[exportFilter]
93
+ if (!Component) {
94
+ return (
95
+ <div className={styles.page}>
96
+ <StoryErrorBoundaryFallback
97
+ name={`${name}/${exportFilter}`}
98
+ error={`Export "${exportFilter}" not found in story "${name}"`}
99
+ />
100
+ </div>
101
+ )
102
+ }
103
+
104
+ // Minimal wrapper for embed mode
105
+ if (isEmbed) {
106
+ return (
107
+ <ThemeProvider colorMode="day">
108
+ <BaseStyles>
109
+ <Component />
110
+ </BaseStyles>
111
+ </ThemeProvider>
112
+ )
113
+ }
114
+
115
+ return (
116
+ <div className={styles.page}>
117
+ <header className={styles.header}>
118
+ <h1 className={styles.title}>{name}</h1>
119
+ <span className={styles.exportBadge}>{exportFilter}</span>
120
+ </header>
121
+ <section className={styles.storySection}>
122
+ <Component />
123
+ </section>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ // Gallery mode — render all exports
129
+ const exportNames = Object.keys(exports)
130
+
131
+ return (
132
+ <div className={styles.page}>
133
+ {!isEmbed && (
134
+ <header className={styles.header}>
135
+ <h1 className={styles.title}>{name}</h1>
136
+ <span className={styles.count}>{exportNames.length} {exportNames.length === 1 ? 'export' : 'exports'}</span>
137
+ </header>
138
+ )}
139
+ {exportNames.map((exportName) => {
140
+ const Component = exports[exportName]
141
+ return (
142
+ <section key={exportName} className={styles.storySection}>
143
+ {!isEmbed && <h2 className={styles.exportName}>{exportName}</h2>}
144
+ <div className={styles.storyContent}>
145
+ <Component />
146
+ </div>
147
+ </section>
148
+ )
149
+ })}
150
+ </div>
151
+ )
152
+ }
@@ -0,0 +1,73 @@
1
+ .page {
2
+ max-width: 960px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ font-family: system-ui, -apple-system, sans-serif;
6
+ }
7
+
8
+ .header {
9
+ display: flex;
10
+ align-items: baseline;
11
+ gap: 12px;
12
+ margin-bottom: 2rem;
13
+ padding-bottom: 1rem;
14
+ border-bottom: 1px solid var(--borderColor-muted, #d0d7de);
15
+ }
16
+
17
+ .title {
18
+ font-size: 1.5rem;
19
+ font-weight: 600;
20
+ margin: 0;
21
+ color: var(--fgColor-default, #1f2328);
22
+ }
23
+
24
+ .exportBadge {
25
+ font-size: 0.875rem;
26
+ font-weight: 500;
27
+ padding: 2px 8px;
28
+ border-radius: 6px;
29
+ background: var(--bgColor-accent-muted, #ddf4ff);
30
+ color: var(--fgColor-accent, #0969da);
31
+ }
32
+
33
+ .count {
34
+ font-size: 0.875rem;
35
+ color: var(--fgColor-muted, #656d76);
36
+ }
37
+
38
+ .storySection {
39
+ margin-bottom: 2rem;
40
+ }
41
+
42
+ .exportName {
43
+ font-size: 1rem;
44
+ font-weight: 500;
45
+ margin: 0 0 0.75rem;
46
+ color: var(--fgColor-default, #1f2328);
47
+ }
48
+
49
+ .storyContent {
50
+ border: 1px solid var(--borderColor-muted, #d0d7de);
51
+ border-radius: 8px;
52
+ padding: 1.5rem;
53
+ background: var(--bgColor-default, #ffffff);
54
+ }
55
+
56
+ .loading {
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ padding: 3rem;
61
+ color: var(--fgColor-muted, #656d76);
62
+ font-size: 0.875rem;
63
+ }
64
+
65
+ .error {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 4px;
69
+ padding: 1rem;
70
+ color: var(--fgColor-danger, #cf222e);
71
+ font-size: 0.875rem;
72
+ line-height: 1.5;
73
+ }