@dfosco/storyboard 0.6.13 → 0.6.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -399,12 +399,7 @@ export async function mountStoryboardCore(config = {}, options = {}) {
399
399
  if (currentHref !== lastHref) {
400
400
  lastHref = currentHref
401
401
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
402
- const pathname = window.location.pathname
403
- const hash = window.location.hash
404
- const stripped = basePath && pathname.startsWith(basePath)
405
- ? pathname.slice(basePath.length) || '/'
406
- : pathname.replace(/^\/branch--[^/]+/, '') || '/'
407
- const src = stripped + hash
402
+ const src = computeEmbedNavigationSrc(window.location.pathname, window.location.hash, basePath)
408
403
  window.parent.postMessage({ type: 'storyboard:embed:navigate', src }, '*')
409
404
  }
410
405
  }
@@ -462,6 +457,48 @@ export async function mountStoryboardCore(config = {}, options = {}) {
462
457
  handlePendingNavigation()
463
458
  }
464
459
 
460
+ /**
461
+ * Compute the route src to broadcast to the parent canvas from a prototype embed iframe.
462
+ *
463
+ * In **dev mode**, the embed iframe is loaded via the isolation entry
464
+ * (`/prototypes.html/<route>`), so `pathname` includes the `/prototypes.html`
465
+ * loader prefix. We strip that prefix and report the remaining route + hash
466
+ * as the src. The hash is preserved verbatim because storyboard URL state
467
+ * (`#key=value` overrides) writes to the hash and the canvas needs the full
468
+ * navigated URL state to round-trip cleanly.
469
+ *
470
+ * Legacy dev shape (0.6.13 and older) put the route in the hash
471
+ * (`/prototypes.html#/<route>`). We still recognize that shape and unwrap
472
+ * the inner hash so canvases never persist `/prototypes.html#/...` as a src
473
+ * (which the canvas-side URL builder would mis-interpret, producing a blank
474
+ * iframe — and cmd+D would duplicate the broken src to every clone).
475
+ *
476
+ * In **prod mode**, the iframe loads the prototype route directly through
477
+ * the canvas SPA, so `pathname` already contains the route — we just strip
478
+ * the base path (and any `/branch--xxx/` deploy prefix) and append the hash.
479
+ *
480
+ * Exported for tests.
481
+ */
482
+ export function computeEmbedNavigationSrc(pathname, hash, basePath = '') {
483
+ const cleanBase = (basePath || '').replace(/\/$/, '')
484
+ const stripped = cleanBase && pathname.startsWith(cleanBase)
485
+ ? pathname.slice(cleanBase.length) || '/'
486
+ : pathname.replace(/^\/branch--[^/]+/, '') || '/'
487
+ // New dev shape: `/prototypes.html/<route>` — strip loader prefix and
488
+ // append hash (storyboard URL state).
489
+ if (stripped.startsWith('/prototypes.html/')) {
490
+ const route = stripped.slice('/prototypes.html'.length) || '/'
491
+ return route + (hash || '')
492
+ }
493
+ // Legacy dev shape (0.6.13 and older): `/prototypes.html` + `#/<route>`.
494
+ if (stripped === '/prototypes.html' || stripped.endsWith('/prototypes.html')) {
495
+ const inner = (hash || '').startsWith('#') ? hash.slice(1) : (hash || '')
496
+ if (!inner) return '/'
497
+ return inner.startsWith('/') ? inner : `/${inner}`
498
+ }
499
+ return stripped + (hash || '')
500
+ }
501
+
465
502
  /**
466
503
  * Check sessionStorage for a pending navigation target.
467
504
  * Used by PageSelector when creating a new canvas page — Vite does a full-reload
@@ -0,0 +1,174 @@
1
+ import { computeEmbedNavigationSrc } from './mountStoryboardCore.js'
2
+
3
+ describe('computeEmbedNavigationSrc', () => {
4
+ describe('prod mode (direct route in pathname)', () => {
5
+ it('strips basePath and appends hash', () => {
6
+ expect(computeEmbedNavigationSrc('/cq-org-enablement', '#sectionA', '/')).toBe(
7
+ '/cq-org-enablement#sectionA',
8
+ )
9
+ })
10
+
11
+ it('handles non-root basePath', () => {
12
+ expect(
13
+ computeEmbedNavigationSrc('/storyboard/cq-org-enablement', '#x', '/storyboard/'),
14
+ ).toBe('/cq-org-enablement#x')
15
+ })
16
+
17
+ it('strips /branch--xxx/ deploy prefix', () => {
18
+ expect(
19
+ computeEmbedNavigationSrc('/branch--my-feature/cq-org-enablement', '#x', '/'),
20
+ ).toBe('/cq-org-enablement#x')
21
+ })
22
+
23
+ it('returns "/" when pathname equals basePath', () => {
24
+ expect(computeEmbedNavigationSrc('/storyboard', '', '/storyboard/')).toBe('/')
25
+ })
26
+
27
+ it('handles missing hash', () => {
28
+ expect(computeEmbedNavigationSrc('/MyProto', '', '/')).toBe('/MyProto')
29
+ })
30
+ })
31
+
32
+ describe('dev mode (new shape: /prototypes.html/<route>)', () => {
33
+ it('strips /prototypes.html prefix from pathname and keeps hash', () => {
34
+ expect(
35
+ computeEmbedNavigationSrc('/prototypes.html/cq-org-enablement', '#cqConfirmOpen=true', '/'),
36
+ ).toBe('/cq-org-enablement#cqConfirmOpen=true')
37
+ })
38
+
39
+ it('preserves multi-key storyboard hash state', () => {
40
+ expect(
41
+ computeEmbedNavigationSrc(
42
+ '/prototypes.html/cq-org-enablement',
43
+ '#cqEnabled=null&cqFlashDismissed=null&cqConfirmOpen=true&cqSettingUp=null',
44
+ '/',
45
+ ),
46
+ ).toBe('/cq-org-enablement#cqEnabled=null&cqFlashDismissed=null&cqConfirmOpen=true&cqSettingUp=null')
47
+ })
48
+
49
+ it('preserves search params (e.g. ?flow=name)', () => {
50
+ expect(
51
+ computeEmbedNavigationSrc(
52
+ '/prototypes.html/cq-org-enablement',
53
+ '',
54
+ '/',
55
+ ),
56
+ ).toBe('/cq-org-enablement')
57
+ })
58
+
59
+ it('handles nested route segments', () => {
60
+ expect(
61
+ computeEmbedNavigationSrc('/prototypes.html/MyProto/SignupForm', '', '/'),
62
+ ).toBe('/MyProto/SignupForm')
63
+ })
64
+
65
+ it('handles non-root basePath', () => {
66
+ expect(
67
+ computeEmbedNavigationSrc(
68
+ '/storyboard/prototypes.html/MyProto',
69
+ '#x=1',
70
+ '/storyboard/',
71
+ ),
72
+ ).toBe('/MyProto#x=1')
73
+ })
74
+
75
+ it('handles branch deploy basePath', () => {
76
+ expect(
77
+ computeEmbedNavigationSrc(
78
+ '/branch--feature-x/prototypes.html/MyProto',
79
+ '#x=1',
80
+ '/branch--feature-x/',
81
+ ),
82
+ ).toBe('/MyProto#x=1')
83
+ })
84
+ })
85
+
86
+ describe('dev mode legacy shape (0.6.13 and older: /prototypes.html + #/<route>)', () => {
87
+ it('uses hash content as src when pathname is /prototypes.html', () => {
88
+ expect(
89
+ computeEmbedNavigationSrc('/prototypes.html', '#/cq-org-enablement', '/'),
90
+ ).toBe('/cq-org-enablement')
91
+ })
92
+
93
+ it('preserves query string inside hash', () => {
94
+ expect(
95
+ computeEmbedNavigationSrc(
96
+ '/prototypes.html',
97
+ '#/cq-org-enablement?flow=cq-org-enablement/default&cqConfirmOpen=true',
98
+ '/',
99
+ ),
100
+ ).toBe('/cq-org-enablement?flow=cq-org-enablement/default&cqConfirmOpen=true')
101
+ })
102
+
103
+ it('handles non-root basePath', () => {
104
+ expect(
105
+ computeEmbedNavigationSrc('/storyboard/prototypes.html', '#/MyProto', '/storyboard/'),
106
+ ).toBe('/MyProto')
107
+ })
108
+
109
+ it('handles branch deploy basePath', () => {
110
+ expect(
111
+ computeEmbedNavigationSrc(
112
+ '/branch--feature-x/prototypes.html',
113
+ '#/MyProto',
114
+ '/branch--feature-x/',
115
+ ),
116
+ ).toBe('/MyProto')
117
+ })
118
+
119
+ it('prefixes inner hash with "/" if missing', () => {
120
+ expect(
121
+ computeEmbedNavigationSrc('/prototypes.html', '#MyProto', '/'),
122
+ ).toBe('/MyProto')
123
+ })
124
+
125
+ it('returns "/" for empty hash', () => {
126
+ expect(computeEmbedNavigationSrc('/prototypes.html', '', '/')).toBe('/')
127
+ })
128
+
129
+ it('returns "/" for hash that is just "#"', () => {
130
+ expect(computeEmbedNavigationSrc('/prototypes.html', '#', '/')).toBe('/')
131
+ })
132
+ })
133
+
134
+ describe('regression — must never produce /prototypes.html prefix as src', () => {
135
+ // 0.6.13 bug: dev embed broadcast pathname+hash, so the parent canvas
136
+ // persisted `/prototypes.html#/route` as the widget src. The URL builder
137
+ // then rebuilt the iframe URL as
138
+ // `/prototypes.html?proto=prototypes.html#/prototypes.html#/route`,
139
+ // which loaded a blank page. Duplicating that widget (cmd+D) carried
140
+ // the broken src to every clone.
141
+ //
142
+ // 0.6.14 bug: hash-based routing in createHashRouter collided with
143
+ // storyboard URL state (which also writes to the hash). Navigating
144
+ // inside the iframe blanked the page because the route part of the
145
+ // hash got URL-encoded into a single nonsense fragment.
146
+ //
147
+ // 0.6.15 (this fix): pathname-based routing — both shapes must
148
+ // round-trip to a clean canvas-app route.
149
+ it('never returns a string starting with /prototypes.html', () => {
150
+ const cases = [
151
+ // legacy hash-form (0.6.13 bug)
152
+ ['/prototypes.html', '#/foo'],
153
+ ['/prototypes.html', '#/foo?bar=1'],
154
+ ['/storyboard/prototypes.html', '#/bar'],
155
+ ['/branch--x/prototypes.html', '#/baz'],
156
+ // new path-form (0.6.15)
157
+ ['/prototypes.html/foo', ''],
158
+ ['/prototypes.html/foo', '#key=value'],
159
+ ['/prototypes.html/foo/bar', '#a=1&b=2'],
160
+ ['/storyboard/prototypes.html/foo', '#x=y'],
161
+ ['/branch--x/prototypes.html/foo', '#x=y'],
162
+ ]
163
+ for (const [pathname, hash] of cases) {
164
+ const basePath = pathname.startsWith('/branch--x')
165
+ ? '/branch--x/'
166
+ : pathname.startsWith('/storyboard')
167
+ ? '/storyboard/'
168
+ : '/'
169
+ const result = computeEmbedNavigationSrc(pathname, hash, basePath)
170
+ expect(result.startsWith('/prototypes.html')).toBe(false)
171
+ }
172
+ })
173
+ })
174
+ })
@@ -5,10 +5,15 @@
5
5
  * the consumer-side `src/prototypes-entry.jsx` so consumers don't need to
6
6
  * maintain the iframe entry themselves.
7
7
  *
8
- * Uses `createHashRouter` so prototype routes live in the URL hash
9
- * (`prototypes.html#/MyProto/SignupForm`). Filters routes by `?proto=` so a
10
- * broken sibling prototype never appears in the matched route's lazy()
11
- * chain (module-graph isolation see .agents/plans/vite-isolation.md).
8
+ * Uses `createBrowserRouter` with `/prototypes.html` as the basename so
9
+ * prototype routes live in the URL path (`prototypes.html/MyProto/SignupForm`),
10
+ * leaving the hash and search free for storyboard URL state (which writes
11
+ * `#key=value` for overrides and `?flow=name` for the active flow).
12
+ *
13
+ * The first path segment after `prototypes.html` is the prototype name —
14
+ * used by `getRoutesForProto()` to filter routes so a broken sibling
15
+ * prototype never appears in the matched route's lazy() chain
16
+ * (module-graph isolation — see .agents/plans/vite-isolation.md).
12
17
  *
13
18
  * CSS chain: relies on `mountStoryboardCore` to inject the compiled
14
19
  * ui-runtime stylesheet (matches index.jsx's mount) — this restores stock
@@ -19,7 +24,7 @@
19
24
  */
20
25
  import { StrictMode } from 'react'
21
26
  import { createRoot } from 'react-dom/client'
22
- import { RouterProvider, createHashRouter } from 'react-router-dom'
27
+ import { RouterProvider, createBrowserRouter } from 'react-router-dom'
23
28
  import { ThemeProvider, BaseStyles } from '@primer/react'
24
29
  import { routes, getRoutesForProto } from './prototypeRoutes.jsx'
25
30
  import ThemeSync from '../../primer/ThemeSync.jsx'
@@ -46,10 +51,20 @@ const storyboardConfig = Object.values(configModules)[0]?.default || {}
46
51
  // - sets up the iframe→parent postMessage navigation/wheel bridge
47
52
  mountStoryboardCore(storyboardConfig, { basePath: import.meta.env.BASE_URL })
48
53
 
49
- const protoParam = new URLSearchParams(window.location.search).get('proto')
54
+ // Derive the prototype name for narrowing. Prefer the first pathname segment
55
+ // after `prototypes.html` (the new browser-router layout); fall back to the
56
+ // legacy `?proto=` query param so old canvases or older builds keep working.
57
+ const basePathNoTrail = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
58
+ const prototypesBasename = `${basePathNoTrail}/prototypes.html`
59
+ const afterBasename = window.location.pathname.startsWith(prototypesBasename)
60
+ ? window.location.pathname.slice(prototypesBasename.length)
61
+ : ''
62
+ const pathProto = (afterBasename.match(/^\/([^/?#]+)/) || [])[1] || ''
63
+ const queryProto = new URLSearchParams(window.location.search).get('proto') || ''
64
+ const protoParam = pathProto || queryProto
50
65
  const activeRoutes = protoParam ? getRoutesForProto(protoParam) : routes
51
66
 
52
- const router = createHashRouter(activeRoutes)
67
+ const router = createBrowserRouter(activeRoutes, { basename: prototypesBasename })
53
68
 
54
69
  const rootElement = document.getElementById('root')
55
70
  const root = createRoot(rootElement)
@@ -27,6 +27,33 @@ function formatName(name) {
27
27
  .replace(/\b\w/g, (c) => c.toUpperCase())
28
28
  }
29
29
 
30
+ // Self-heal canvases that persisted a dev-isolation loader URL as the widget src.
31
+ // Two legacy shapes exist:
32
+ // - `/prototypes.html#/MyProto?...` — 0.6.13 and older, when the iframe
33
+ // broadcast pathname+hash and routing lived in the hash
34
+ // - `/prototypes.html/MyProto?...` — could appear if a future broadcaster
35
+ // ever forwards the loader path verbatim with the new browser-router layout
36
+ // Without normalization the URL builder treats "prototypes.html" as the
37
+ // prototype name and produces a blank iframe — and cmd+D duplicates inherit
38
+ // the broken src. Pure, deterministic, safe to call on every render.
39
+ function normalizeLegacyEmbedSrc(src) {
40
+ if (typeof src !== 'string' || !src) return src
41
+ // Hash-form: `/prototypes.html#/inner` or `/storyboard/prototypes.html#/inner`
42
+ const hashMatch = src.match(/^\/?(?:[^#]*\/)?prototypes\.html#(.*)$/)
43
+ if (hashMatch) {
44
+ const inner = hashMatch[1] || ''
45
+ if (!inner) return '/'
46
+ return inner.startsWith('/') ? inner : `/${inner}`
47
+ }
48
+ // Path-form: `/prototypes.html/inner...` or `/storyboard/prototypes.html/inner...`
49
+ const pathMatch = src.match(/^\/?(?:[^#?]*\/)?prototypes\.html(\/[^]*)$/)
50
+ if (pathMatch) {
51
+ const inner = pathMatch[1] || ''
52
+ return inner || '/'
53
+ }
54
+ return src
55
+ }
56
+
30
57
  function resolveCanvasThemeFromStorage() {
31
58
  if (typeof localStorage === 'undefined') return 'light'
32
59
  let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
@@ -46,7 +73,7 @@ function resolveCanvasThemeFromStorage() {
46
73
  const HEADER_HEIGHT = 37
47
74
 
48
75
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
49
- const src = readProp(props, 'src', prototypeEmbedSchema)
76
+ const src = normalizeLegacyEmbedSrc(readProp(props, 'src', prototypeEmbedSchema))
50
77
  const width = readProp(props, 'width', prototypeEmbedSchema) || 800
51
78
  const height = readProp(props, 'height', prototypeEmbedSchema) || 600
52
79
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema) || 100
@@ -58,13 +85,15 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
58
85
  // - rawSrc — the iframe URL. In DEV this routes through the isolated
59
86
  // prototypes.html entry so a broken prototype's transform/HMR errors
60
87
  // stay inside the iframe (see .agents/plans/vite-isolation.md):
61
- // /MyProto/SignupForm becomes prototypes.html?proto=MyProto#/MyProto/SignupForm.
62
- // In PROD it loads the prototype path directly through the canvas SPA
63
- // (prototypes.html is a build-time isolation artifact that must not
64
- // leak into deployed URLs).
88
+ // /MyProto/SignupForm becomes prototypes.html/MyProto/SignupForm.
89
+ // Routing happens in the *pathname* (createBrowserRouter), not in the
90
+ // hash, so the hash and search stay free for storyboard URL state
91
+ // (`?flow=...` and `#key=value` overrides). In PROD it loads the
92
+ // prototype path directly through the canvas SPA (prototypes.html is
93
+ // a build-time isolation artifact that must not leak into deployed URLs).
65
94
  // - externalSrc — the URL used by "Open in new tab". Always direct
66
95
  // (`${basePath}/<protoPath>`), never prototypes.html, even in dev —
67
- // opening prototypes.html#/... in a fresh tab is a leaky surprise
96
+ // opening prototypes.html/... in a fresh tab is a leaky surprise
68
97
  // for users navigating from the canvas.
69
98
  // External http(s) URLs are left alone in both cases. basePath already
70
99
  // carries the /branch--xxx/ prefix on branch deploys, so both work for
@@ -94,13 +123,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
94
123
  if (import.meta.env.PROD) {
95
124
  return { rawSrc: directUrl, externalSrc: directUrl }
96
125
  }
97
- // Dev iframe: prototypes.html with ?proto= narrowing. The consumer's
98
- // prototypes-entry.jsx reads ?proto= and calls getRoutesForProto();
99
- // older scaffolds harmlessly ignore the param and load the full tree.
100
- const pathOnly = pathAndQuery.split('?')[0]
101
- const protoName = pathOnly.split('/').filter(Boolean)[0] || ''
102
- const queryStr = protoName ? `?proto=${encodeURIComponent(protoName)}` : ''
103
- const iframeUrl = `${basePath}/prototypes.html${queryStr}#${routePath}${suffix}`
126
+ // Dev iframe: prototypes.html with the route as a sub-path. The library
127
+ // middleware (`data-plugin.js`) serves the same HTML shell for
128
+ // `/prototypes.html/*` so a hard reload / open-in-new-tab works. The
129
+ // prototype name (first path segment) narrows getRoutesForProto so a
130
+ // broken sibling prototype can't poison the lazy() chain. Hash and
131
+ // search stay attached so storyboard URL state (`#key=value` overrides,
132
+ // `?flow=name`) survives the redirect through the isolation entry.
133
+ const iframeUrl = `${basePath}/prototypes.html${routePath}${suffix}`
104
134
  return { rawSrc: iframeUrl, externalSrc: directUrl }
105
135
  }, [src, basePath, baseSegment])
106
136
 
@@ -309,7 +339,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
309
339
  function handleMessage(e) {
310
340
  if (e.source !== iframeRef.current?.contentWindow) return
311
341
  if (e.data?.type !== 'storyboard:embed:navigate') return
312
- const newSrc = e.data.src
342
+ // Defensive: older consumer embeds reported `/prototypes.html#/route`
343
+ // here (loader path + hash). Normalize to the inner route so we never
344
+ // persist the broken form.
345
+ const newSrc = normalizeLegacyEmbedSrc(e.data.src)
313
346
  if (newSrc && newSrc !== src) {
314
347
  const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
315
348
  onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
@@ -1248,7 +1248,7 @@ export default function storyboardDataPlugin() {
1248
1248
  url = url.slice(baseNoTrail.length) || '/'
1249
1249
  }
1250
1250
  const cleanUrl = url.split('?')[0].split('#')[0]
1251
- if (cleanUrl !== '/prototypes.html') return next()
1251
+ if (cleanUrl !== '/prototypes.html' && !cleanUrl.startsWith('/prototypes.html/')) return next()
1252
1252
 
1253
1253
  // Theme bootstrap mirrors index.html so the iframe paints with the
1254
1254
  // correct color mode on first frame (before mountStoryboardCore