@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.2

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 (35) hide show
  1. package/package.json +3 -3
  2. package/src/Viewfinder.jsx +5 -3
  3. package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
  4. package/src/canvas/CanvasControls.jsx +2 -59
  5. package/src/canvas/CanvasControls.module.css +0 -29
  6. package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
  7. package/src/canvas/CanvasPage.jsx +801 -68
  8. package/src/canvas/CanvasPage.module.css +47 -2
  9. package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
  10. package/src/canvas/canvasApi.js +8 -0
  11. package/src/canvas/computeCanvasBounds.test.js +121 -0
  12. package/src/canvas/useCanvas.js +2 -1
  13. package/src/canvas/useUndoRedo.js +86 -0
  14. package/src/canvas/useUndoRedo.test.js +231 -0
  15. package/src/canvas/widgets/ComponentWidget.jsx +9 -7
  16. package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
  17. package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
  18. package/src/canvas/widgets/ImageWidget.jsx +115 -0
  19. package/src/canvas/widgets/ImageWidget.module.css +39 -0
  20. package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
  21. package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
  22. package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
  23. package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
  24. package/src/canvas/widgets/StickyNote.jsx +21 -16
  25. package/src/canvas/widgets/StickyNote.test.jsx +24 -4
  26. package/src/canvas/widgets/WidgetChrome.jsx +276 -50
  27. package/src/canvas/widgets/WidgetChrome.module.css +91 -10
  28. package/src/canvas/widgets/figmaUrl.js +118 -0
  29. package/src/canvas/widgets/figmaUrl.test.js +139 -0
  30. package/src/canvas/widgets/index.js +4 -0
  31. package/src/canvas/widgets/widgetConfig.js +74 -6
  32. package/src/canvas/widgets/widgetConfig.test.js +46 -0
  33. package/src/canvas/widgets/widgetProps.js +2 -0
  34. package/src/context.jsx +34 -4
  35. package/src/context.test.jsx +13 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Figma URL utilities — detection, sanitization, and embed URL transformation.
3
+ *
4
+ * Supports three Figma link types:
5
+ * - Board: figma.com/board/{key}/{name}
6
+ * - Design: figma.com/design/{key}/{name}
7
+ * - Proto: figma.com/proto/{key}/{name}
8
+ */
9
+
10
+ const FIGMA_HOST_RE = /^(www\.)?figma\.com$/
11
+ const FIGMA_PATH_RE = /^\/(board|design|proto)\/[A-Za-z0-9]+/
12
+
13
+ /** Params to strip from stored/embed URLs (session/tracking tokens). */
14
+ const STRIP_PARAMS = new Set(['t'])
15
+
16
+ /**
17
+ * Check whether a URL string is a Figma board, design, or prototype link.
18
+ * @param {string} url
19
+ * @returns {boolean}
20
+ */
21
+ export function isFigmaUrl(url) {
22
+ try {
23
+ const parsed = new URL(url)
24
+ return FIGMA_HOST_RE.test(parsed.hostname) && FIGMA_PATH_RE.test(parsed.pathname)
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Return the Figma link type: 'board', 'design', or 'proto'.
32
+ * Returns null for non-Figma URLs.
33
+ * @param {string} url
34
+ * @returns {'board' | 'design' | 'proto' | null}
35
+ */
36
+ export function getFigmaType(url) {
37
+ try {
38
+ const parsed = new URL(url)
39
+ if (!FIGMA_HOST_RE.test(parsed.hostname)) return null
40
+ const match = parsed.pathname.match(FIGMA_PATH_RE)
41
+ if (!match) return null
42
+ return match[1]
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Sanitize a Figma URL for storage — strips tracking params like `t`.
50
+ * Returns a canonical www.figma.com URL safe to persist in canvas data.
51
+ * @param {string} url — raw pasted Figma URL
52
+ * @returns {string} sanitized URL
53
+ */
54
+ export function sanitizeFigmaUrl(url) {
55
+ try {
56
+ const parsed = new URL(url)
57
+ if (!FIGMA_HOST_RE.test(parsed.hostname)) return url
58
+ // Normalize to www.figma.com
59
+ parsed.hostname = 'www.figma.com'
60
+ for (const key of STRIP_PARAMS) {
61
+ parsed.searchParams.delete(key)
62
+ }
63
+ return parsed.toString()
64
+ } catch {
65
+ return url
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Transform a Figma URL into its embed counterpart.
71
+ *
72
+ * - Replaces host with `embed.figma.com`
73
+ * - Strips tracking params (`t`)
74
+ * - Appends `embed-host=share`
75
+ *
76
+ * @param {string} url — original Figma URL
77
+ * @returns {string} embed URL, or the original URL if it can't be transformed
78
+ */
79
+ export function toFigmaEmbedUrl(url) {
80
+ try {
81
+ const parsed = new URL(url)
82
+ if (!FIGMA_HOST_RE.test(parsed.hostname)) return url
83
+
84
+ parsed.hostname = 'embed.figma.com'
85
+
86
+ // Strip tracking/session params
87
+ for (const key of STRIP_PARAMS) {
88
+ parsed.searchParams.delete(key)
89
+ }
90
+
91
+ // Ensure embed-host is set
92
+ parsed.searchParams.set('embed-host', 'share')
93
+
94
+ return parsed.toString()
95
+ } catch {
96
+ return url
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Extract a human-readable title from a Figma URL.
102
+ * Uses the name segment from the path (e.g. "Security-Products-HQ").
103
+ * @param {string} url
104
+ * @returns {string}
105
+ */
106
+ export function getFigmaTitle(url) {
107
+ try {
108
+ const parsed = new URL(url)
109
+ // Path: /board|design|proto/{key}/{name}
110
+ const segments = parsed.pathname.split('/').filter(Boolean)
111
+ if (segments.length >= 3) {
112
+ return segments[2].replace(/-/g, ' ')
113
+ }
114
+ return 'Figma'
115
+ } catch {
116
+ return 'Figma'
117
+ }
118
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isFigmaUrl, getFigmaType, toFigmaEmbedUrl, getFigmaTitle, sanitizeFigmaUrl } from './figmaUrl.js'
3
+
4
+ describe('isFigmaUrl', () => {
5
+ it('detects board URLs', () => {
6
+ expect(isFigmaUrl('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0')).toBe(true)
7
+ })
8
+
9
+ it('detects design URLs', () => {
10
+ expect(isFigmaUrl('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=103-4739')).toBe(true)
11
+ })
12
+
13
+ it('detects proto URLs', () => {
14
+ expect(isFigmaUrl('https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=122-9632&p=f&t=9XSi047pSbt81sZS-0&scaling=min-zoom')).toBe(true)
15
+ })
16
+
17
+ it('works without www prefix', () => {
18
+ expect(isFigmaUrl('https://figma.com/board/abc123/My-Board')).toBe(true)
19
+ })
20
+
21
+ it('rejects non-Figma URLs', () => {
22
+ expect(isFigmaUrl('https://example.com/board/abc')).toBe(false)
23
+ expect(isFigmaUrl('https://www.figma.com/file/abc')).toBe(false)
24
+ expect(isFigmaUrl('not a url')).toBe(false)
25
+ expect(isFigmaUrl('')).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe('getFigmaType', () => {
30
+ it('returns board for board URLs', () => {
31
+ expect(getFigmaType('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Name')).toBe('board')
32
+ })
33
+
34
+ it('returns design for design URLs', () => {
35
+ expect(getFigmaType('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Name')).toBe('design')
36
+ })
37
+
38
+ it('returns proto for proto URLs', () => {
39
+ expect(getFigmaType('https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Name')).toBe('proto')
40
+ })
41
+
42
+ it('returns null for non-Figma URLs', () => {
43
+ expect(getFigmaType('https://example.com')).toBeNull()
44
+ })
45
+ })
46
+
47
+ describe('toFigmaEmbedUrl', () => {
48
+ it('transforms board URL', () => {
49
+ const input = 'https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0'
50
+ const result = toFigmaEmbedUrl(input)
51
+ const parsed = new URL(result)
52
+
53
+ expect(parsed.hostname).toBe('embed.figma.com')
54
+ expect(parsed.pathname).toBe('/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ')
55
+ expect(parsed.searchParams.get('node-id')).toBe('0-1')
56
+ expect(parsed.searchParams.get('embed-host')).toBe('share')
57
+ expect(parsed.searchParams.has('t')).toBe(false)
58
+ })
59
+
60
+ it('transforms design URL', () => {
61
+ const input = 'https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=103-4739'
62
+ const result = toFigmaEmbedUrl(input)
63
+ const parsed = new URL(result)
64
+
65
+ expect(parsed.hostname).toBe('embed.figma.com')
66
+ expect(parsed.pathname).toBe('/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')
67
+ expect(parsed.searchParams.get('node-id')).toBe('103-4739')
68
+ expect(parsed.searchParams.get('embed-host')).toBe('share')
69
+ })
70
+
71
+ it('transforms proto URL and preserves relevant params', () => {
72
+ const input = 'https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=122-9632&p=f&t=9XSi047pSbt81sZS-0&scaling=min-zoom&content-scaling=fixed&page-id=103%3A4739&starting-point-node-id=140%3A5949'
73
+ const result = toFigmaEmbedUrl(input)
74
+ const parsed = new URL(result)
75
+
76
+ expect(parsed.hostname).toBe('embed.figma.com')
77
+ expect(parsed.pathname).toBe('/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')
78
+ expect(parsed.searchParams.get('node-id')).toBe('122-9632')
79
+ expect(parsed.searchParams.get('p')).toBe('f')
80
+ expect(parsed.searchParams.get('scaling')).toBe('min-zoom')
81
+ expect(parsed.searchParams.get('content-scaling')).toBe('fixed')
82
+ expect(parsed.searchParams.get('page-id')).toBe('103:4739')
83
+ expect(parsed.searchParams.get('starting-point-node-id')).toBe('140:5949')
84
+ expect(parsed.searchParams.get('embed-host')).toBe('share')
85
+ expect(parsed.searchParams.has('t')).toBe(false)
86
+ })
87
+
88
+ it('returns original URL for non-Figma URLs', () => {
89
+ expect(toFigmaEmbedUrl('https://example.com')).toBe('https://example.com')
90
+ })
91
+ })
92
+
93
+ describe('getFigmaTitle', () => {
94
+ it('extracts title from board URL', () => {
95
+ expect(getFigmaTitle('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ')).toBe('Security Products HQ')
96
+ })
97
+
98
+ it('extracts title from design URL', () => {
99
+ expect(getFigmaTitle('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')).toBe("Darby s copilot metric sandbox")
100
+ })
101
+
102
+ it('returns Figma for URLs without name segment', () => {
103
+ expect(getFigmaTitle('https://www.figma.com/board/abc')).toBe('Figma')
104
+ })
105
+ })
106
+
107
+ describe('sanitizeFigmaUrl', () => {
108
+ it('strips tracking param and normalizes to www.figma.com', () => {
109
+ const input = 'https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0'
110
+ const result = sanitizeFigmaUrl(input)
111
+ const parsed = new URL(result)
112
+
113
+ expect(parsed.hostname).toBe('www.figma.com')
114
+ expect(parsed.searchParams.get('node-id')).toBe('0-1')
115
+ expect(parsed.searchParams.has('t')).toBe(false)
116
+ })
117
+
118
+ it('normalizes figma.com to www.figma.com', () => {
119
+ const input = 'https://figma.com/board/abc/Name?node-id=0-1'
120
+ const result = sanitizeFigmaUrl(input)
121
+ expect(new URL(result).hostname).toBe('www.figma.com')
122
+ })
123
+
124
+ it('preserves all non-tracking params for proto URLs', () => {
125
+ const input = 'https://www.figma.com/proto/abc/Name?node-id=1-2&p=f&t=TOKEN&scaling=min-zoom&page-id=103%3A4739'
126
+ const result = sanitizeFigmaUrl(input)
127
+ const parsed = new URL(result)
128
+
129
+ expect(parsed.searchParams.get('node-id')).toBe('1-2')
130
+ expect(parsed.searchParams.get('p')).toBe('f')
131
+ expect(parsed.searchParams.get('scaling')).toBe('min-zoom')
132
+ expect(parsed.searchParams.get('page-id')).toBe('103:4739')
133
+ expect(parsed.searchParams.has('t')).toBe(false)
134
+ })
135
+
136
+ it('returns non-Figma URLs unchanged', () => {
137
+ expect(sanitizeFigmaUrl('https://example.com')).toBe('https://example.com')
138
+ })
139
+ })
@@ -2,6 +2,8 @@ import StickyNote from './StickyNote.jsx'
2
2
  import MarkdownBlock from './MarkdownBlock.jsx'
3
3
  import PrototypeEmbed from './PrototypeEmbed.jsx'
4
4
  import LinkPreview from './LinkPreview.jsx'
5
+ import ImageWidget from './ImageWidget.jsx'
6
+ import FigmaEmbed from './FigmaEmbed.jsx'
5
7
 
6
8
  /**
7
9
  * Maps widget type strings to their React components.
@@ -12,6 +14,8 @@ export const widgetRegistry = {
12
14
  'markdown': MarkdownBlock,
13
15
  'prototype': PrototypeEmbed,
14
16
  'link-preview': LinkPreview,
17
+ 'image': ImageWidget,
18
+ 'figma-embed': FigmaEmbed,
15
19
  }
16
20
 
17
21
  /**
@@ -6,9 +6,44 @@
6
6
  *
7
7
  * The config is the single source of truth for widget definitions —
8
8
  * prop schemas, feature lists, labels, and icons all come from here.
9
+ *
10
+ * Supports `$variable` references in string values, resolved from
11
+ * the top-level `variables` object in widgets.config.json.
9
12
  */
10
13
  import widgetsConfig from '@dfosco/storyboard-core/widgets.config.json'
11
14
 
15
+ /** Variables defined in config — used to resolve `$key` references. */
16
+ const variables = widgetsConfig.variables || {}
17
+
18
+ /**
19
+ * Resolve `$variable` references in a string value.
20
+ * Returns the original value if it's not a string or doesn't start with `$`.
21
+ */
22
+ function resolveVar(value) {
23
+ if (typeof value !== 'string' || !value.startsWith('$')) return value
24
+ const key = value.slice(1)
25
+ return variables[key] ?? value
26
+ }
27
+
28
+ /**
29
+ * Resolve all string values in a feature object, including nested items.
30
+ */
31
+ function resolveFeature(feature) {
32
+ const resolved = {}
33
+ for (const [key, val] of Object.entries(feature)) {
34
+ if (key === 'items' && Array.isArray(val)) {
35
+ resolved[key] = val.map((item) => {
36
+ const r = {}
37
+ for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
38
+ return r
39
+ })
40
+ } else {
41
+ resolved[key] = resolveVar(val)
42
+ }
43
+ }
44
+ return resolved
45
+ }
46
+
12
47
  /**
13
48
  * Convert a config prop definition to the schema shape used by widgetProps.js.
14
49
  * Config uses `"default"`, schema uses `"defaultValue"`.
@@ -42,19 +77,52 @@ function buildSchemas() {
42
77
  return result
43
78
  }
44
79
 
80
+ /**
81
+ * Build resolved widget type entries with variables expanded in features.
82
+ */
83
+ function buildWidgetTypes() {
84
+ const result = {}
85
+ for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
86
+ result[type] = {
87
+ ...def,
88
+ features: (def.features || []).map(resolveFeature),
89
+ }
90
+ }
91
+ return result
92
+ }
93
+
45
94
  /** All widget schemas, keyed by type string. */
46
95
  export const schemas = buildSchemas()
47
96
 
48
- /** Full widget config entries, keyed by type string. */
49
- export const widgetTypes = widgetsConfig.widgets
97
+ /** Full widget config entries (with resolved variables), keyed by type string. */
98
+ export const widgetTypes = buildWidgetTypes()
50
99
 
51
100
  /**
52
101
  * Get the feature list for a widget type.
102
+ * In production, only features with `prod: true` are returned.
103
+ * In dev, all features are returned.
53
104
  * @param {string} type — widget type string
54
- * @returns {Array} features array from config, or empty array
105
+ * @returns {Array} features array from config (variables resolved), or empty array
55
106
  */
56
107
  export function getFeatures(type) {
57
- return widgetTypes[type]?.features ?? []
108
+ const features = widgetTypes[type]?.features ?? []
109
+ if (import.meta.env?.PROD) {
110
+ return features.filter(f => f.prod)
111
+ }
112
+ return features
113
+ }
114
+
115
+ /**
116
+ * Check if a widget type supports resize in the current environment.
117
+ * Returns false if resize is disabled, or if in production and prod is not true.
118
+ * @param {string} type — widget type string
119
+ * @returns {boolean}
120
+ */
121
+ export function isResizable(type) {
122
+ const resize = widgetTypes[type]?.resize
123
+ if (!resize?.enabled) return false
124
+ if (import.meta.env?.PROD && !resize.prod) return false
125
+ return true
58
126
  }
59
127
 
60
128
  /**
@@ -70,10 +138,10 @@ export function getWidgetMeta(type) {
70
138
 
71
139
  /**
72
140
  * Get all widget types as an array of { type, label, icon } for menus.
73
- * Excludes link-preview which is created via paste only.
141
+ * Excludes link-preview, image, and figma-embed which are created via paste only.
74
142
  */
75
143
  export function getMenuWidgetTypes() {
76
144
  return Object.entries(widgetTypes)
77
- .filter(([type]) => type !== 'link-preview')
145
+ .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
78
146
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
79
147
  }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
3
+
4
+ describe('isResizable', () => {
5
+ // Vitest runs with import.meta.env.PROD = true, so prod: false widgets
6
+ // correctly return false. This tests the production behavior.
7
+ it('returns false for resize-enabled widgets when prod is false (production env)', () => {
8
+ expect(isResizable('sticky-note')).toBe(false)
9
+ expect(isResizable('prototype')).toBe(false)
10
+ expect(isResizable('figma-embed')).toBe(false)
11
+ expect(isResizable('image')).toBe(false)
12
+ expect(isResizable('component')).toBe(false)
13
+ })
14
+
15
+ it('returns false for widget types with resize disabled', () => {
16
+ expect(isResizable('markdown')).toBe(false)
17
+ expect(isResizable('link-preview')).toBe(false)
18
+ })
19
+
20
+ it('returns false for unknown widget types', () => {
21
+ expect(isResizable('nonexistent')).toBe(false)
22
+ })
23
+ })
24
+
25
+ describe('getFeatures', () => {
26
+ it('returns features array for known widget types', () => {
27
+ const features = getFeatures('sticky-note')
28
+ expect(Array.isArray(features)).toBe(true)
29
+ expect(features.length).toBeGreaterThan(0)
30
+ })
31
+
32
+ it('returns empty array for unknown widget types', () => {
33
+ expect(getFeatures('nonexistent')).toEqual([])
34
+ })
35
+ })
36
+
37
+ describe('getWidgetMeta', () => {
38
+ it('returns label and icon for known types', () => {
39
+ const meta = getWidgetMeta('sticky-note')
40
+ expect(meta).toEqual({ label: 'Sticky Note', icon: '📝' })
41
+ })
42
+
43
+ it('returns null for unknown types', () => {
44
+ expect(getWidgetMeta('nonexistent')).toBeNull()
45
+ })
46
+ })
@@ -127,3 +127,5 @@ export const stickyNoteSchema = schemas['sticky-note']
127
127
  export const markdownSchema = schemas['markdown']
128
128
  export const prototypeEmbedSchema = schemas['prototype']
129
129
  export const linkPreviewSchema = schemas['link-preview']
130
+ export const imageSchema = schemas['image']
131
+ export const figmaEmbedSchema = schemas['figma-embed']
package/src/context.jsx CHANGED
@@ -22,6 +22,11 @@ function matchCanvasRoute(pathname) {
22
22
  return canvasRouteMap.get(normalized) || null
23
23
  }
24
24
 
25
+ function isCanvasPath(pathname) {
26
+ const normalized = pathname.replace(/\/+$/, '') || '/'
27
+ return normalized === '/canvas' || normalized.startsWith('/canvas/')
28
+ }
29
+
25
30
  /**
26
31
  * Derives the top-level prototype name from a pathname.
27
32
  * "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
@@ -62,6 +67,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
62
67
 
63
68
  // Canvas route detection — matches current URL against registered canvas routes
64
69
  const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
70
+ const isMissingCanvasRoute = useMemo(
71
+ () => isCanvasPath(location.pathname) && !canvasName,
72
+ [location.pathname, canvasName],
73
+ )
65
74
 
66
75
  const searchParams = new URLSearchParams(location.search)
67
76
  const sceneParam = searchParams.get('flow') || searchParams.get('scene')
@@ -70,7 +79,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
70
79
 
71
80
  // Resolve flow name with prototype scoping (skip for canvas pages)
72
81
  const activeFlowName = useMemo(() => {
73
- if (canvasName) return null
82
+ if (canvasName || isMissingCanvasRoute) return null
74
83
  const requested = sceneParam || flowName || sceneName
75
84
  if (requested) {
76
85
  // Allow fully-scoped flow names from URLs/widgets without re-prefixing
@@ -94,7 +103,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
94
103
  // 4. Global default — or null if no flow exists at all
95
104
  if (flowExists('default')) return 'default'
96
105
  return null
97
- }, [canvasName, sceneParam, flowName, sceneName, prototypeName, pageFlow])
106
+ }, [canvasName, isMissingCanvasRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
98
107
 
99
108
  // Auto-install body class sync (sb-key--value classes on <body>)
100
109
  useEffect(() => installBodyClassSync(), [])
@@ -117,7 +126,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
117
126
 
118
127
  // Skip flow loading for canvas pages and flow-less pages
119
128
  const { data, error } = useMemo(() => {
120
- if (canvasName) return { data: null, error: null }
129
+ if (canvasName || isMissingCanvasRoute) return { data: null, error: null }
121
130
  if (!activeFlowName) return { data: {}, error: null }
122
131
  try {
123
132
  let flowData = loadFlow(activeFlowName)
@@ -136,7 +145,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
136
145
  } catch (err) {
137
146
  return { data: null, error: err.message }
138
147
  }
139
- }, [canvasName, activeFlowName, recordName, recordParam, params, prototypeName])
148
+ }, [canvasName, isMissingCanvasRoute, activeFlowName, recordName, recordParam, params, prototypeName])
140
149
 
141
150
  // Canvas pages get their own rendering path — no flow data needed
142
151
  if (canvasName) {
@@ -157,6 +166,27 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
157
166
  )
158
167
  }
159
168
 
169
+ if (isMissingCanvasRoute) {
170
+ const currentUrl = `${location.pathname}${location.search}`
171
+ const truncatedUrl = currentUrl.length > 60
172
+ ? currentUrl.slice(0, 60) + '…'
173
+ : currentUrl
174
+
175
+ return (
176
+ <main className={styles.container}>
177
+ <div className={styles.banner}>
178
+ <strong>Canvas not found</strong>
179
+ No canvas matches this route.
180
+ </div>
181
+ <p className={styles.meta}>
182
+ Tried to open{' '}
183
+ <a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
184
+ </p>
185
+ <a className={styles.homeLink} href="/">← Go to index page</a>
186
+ </main>
187
+ )
188
+ }
189
+
160
190
  const value = {
161
191
  data,
162
192
  error,
@@ -280,4 +280,17 @@ describe('StoryboardProvider', () => {
280
280
  )
281
281
  expect(screen.getByTestId('ctx')).toHaveTextContent('Global Default')
282
282
  })
283
+
284
+ it('shows a simple 404 for unknown canvas routes with an index link', () => {
285
+ mockUseLocation.mockReturnValue({ pathname: '/canvas/unknown-board', search: '', hash: '' })
286
+
287
+ render(
288
+ <StoryboardProvider>
289
+ <ContextReader />
290
+ </StoryboardProvider>,
291
+ )
292
+
293
+ expect(screen.getByText('Canvas not found')).toBeInTheDocument()
294
+ expect(screen.getByRole('link', { name: /go to index page/i })).toHaveAttribute('href', '/')
295
+ })
283
296
  })