@dfosco/storyboard-core 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 (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Config Schema — canonical shape and defaults for storyboard.config.json.
3
+ *
4
+ * Every consumer of storyboard.config.json should import `getConfig()` to get
5
+ * a fully defaulted, validated config object. New keys added here are safe for
6
+ * existing projects — they always have defaults.
7
+ *
8
+ * @module configSchema
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} PasteRule
13
+ * @property {string} match — regex string tested against pasted URLs
14
+ * @property {string} widget — widget type to create (e.g. "figma-embed", "link-preview")
15
+ * @property {Record<string, string>} [propsMap] — static props merged into widget props
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} CanvasConfig
20
+ * @property {PasteRule[]} pasteRules — URL→widget conversion rules (evaluated in order, first match wins)
21
+ * @property {{ embedBehavior: string, ghGuard: string }} github — GitHub-specific embed settings
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} CommandPaletteConfig
26
+ * @property {string[]} providers — provider IDs to enable
27
+ * @property {string} ranking — result ranking strategy
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} StoryboardConfig
32
+ * @property {string} [customDomain]
33
+ * @property {string} [devDomain]
34
+ * @property {{ owner: string, name: string }} [repository]
35
+ * @property {{ enabled: boolean }} [modes]
36
+ * @property {{ discussions: { category: string } }} [comments]
37
+ * @property {Record<string, boolean>} [plugins]
38
+ * @property {{ enabled?: boolean, features?: Record<string, boolean>, partials?: Array }} [workshop]
39
+ * @property {Record<string, boolean>} [featureFlags]
40
+ * @property {{ hide?: string[] }} [ui]
41
+ * @property {object} [toolbar]
42
+ * @property {CanvasConfig} [canvas]
43
+ * @property {CommandPaletteConfig} [commandPalette]
44
+ */
45
+
46
+ /** Built-in paste rules shipped with storyboard. */
47
+ export const builtinPasteRules = [
48
+ {
49
+ id: 'figma',
50
+ match: 'https?://(?:www\\.)?figma\\.com/',
51
+ widget: 'figma-embed',
52
+ propsMap: { width: 800, height: 450 },
53
+ },
54
+ ]
55
+
56
+ /** Default config values. Every key here is safe to access without null checks. */
57
+ export const configDefaults = {
58
+ customDomain: '',
59
+ devDomain: '',
60
+ repository: { owner: '', name: '' },
61
+ modes: { enabled: false },
62
+ comments: { discussions: { category: 'Comments' } },
63
+ plugins: {},
64
+ workshop: {
65
+ enabled: false,
66
+ features: { createPrototype: true, createFlow: true, createCanvas: true },
67
+ },
68
+ featureFlags: {},
69
+ ui: {},
70
+ toolbar: {},
71
+ canvas: {
72
+ pasteRules: builtinPasteRules,
73
+ github: {
74
+ embedBehavior: 'link-preview', // "link-preview" | "rich-embed"
75
+ ghGuard: 'copy', // "copy" | "link" | "off"
76
+ },
77
+ },
78
+ commandPalette: {
79
+ providers: ['prototypes', 'flows', 'canvases', 'pages'],
80
+ ranking: 'frecency', // "recent" | "alphabetical" | "frecency"
81
+ },
82
+ }
83
+
84
+ /**
85
+ * Deep-merge helper that replaces arrays instead of concatenating.
86
+ * Objects are recursively merged; all other values are overwritten.
87
+ */
88
+ function mergeConfig(defaults, overrides) {
89
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
90
+ return overrides ?? defaults
91
+ }
92
+ const result = { ...defaults }
93
+ for (const key of Object.keys(overrides)) {
94
+ const val = overrides[key]
95
+ if (val === undefined) continue
96
+ if (Array.isArray(val)) {
97
+ // Arrays replace (e.g. pasteRules, providers) — no concat
98
+ result[key] = val
99
+ } else if (val && typeof val === 'object' && !Array.isArray(val) && typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) {
100
+ result[key] = mergeConfig(defaults[key], val)
101
+ } else {
102
+ result[key] = val
103
+ }
104
+ }
105
+ return result
106
+ }
107
+
108
+ /**
109
+ * Return a fully defaulted config by merging user-provided values over defaults.
110
+ * Safe to call with an empty object or undefined — returns full defaults.
111
+ *
112
+ * @param {Partial<StoryboardConfig>} [raw={}]
113
+ * @returns {StoryboardConfig}
114
+ */
115
+ export function getConfig(raw = {}) {
116
+ return mergeConfig(configDefaults, raw)
117
+ }
118
+
119
+ /**
120
+ * Return a copy of the bare defaults (no user overrides).
121
+ * @returns {StoryboardConfig}
122
+ */
123
+ export function getConfigDefaults() {
124
+ return JSON.parse(JSON.stringify(configDefaults))
125
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getConfig, getConfigDefaults, configDefaults, builtinPasteRules } from './configSchema.js'
3
+
4
+ describe('configSchema', () => {
5
+ describe('getConfigDefaults', () => {
6
+ it('returns a full defaults object', () => {
7
+ const d = getConfigDefaults()
8
+ expect(d.canvas).toBeDefined()
9
+ expect(d.commandPalette).toBeDefined()
10
+ expect(d.repository).toEqual({ owner: '', name: '' })
11
+ })
12
+
13
+ it('returns a fresh copy each time', () => {
14
+ const a = getConfigDefaults()
15
+ const b = getConfigDefaults()
16
+ expect(a).not.toBe(b)
17
+ expect(a).toEqual(b)
18
+ })
19
+ })
20
+
21
+ describe('getConfig', () => {
22
+ it('returns full defaults when called with empty object', () => {
23
+ const c = getConfig({})
24
+ expect(c.canvas.pasteRules).toEqual(builtinPasteRules)
25
+ expect(c.canvas.github.embedBehavior).toBe('link-preview')
26
+ expect(c.canvas.github.ghGuard).toBe('copy')
27
+ expect(c.commandPalette.providers).toEqual(['prototypes', 'flows', 'canvases', 'pages'])
28
+ expect(c.commandPalette.ranking).toBe('frecency')
29
+ })
30
+
31
+ it('returns full defaults when called with undefined', () => {
32
+ const c = getConfig()
33
+ expect(c.canvas).toBeDefined()
34
+ expect(c.commandPalette).toBeDefined()
35
+ })
36
+
37
+ it('merges user config over defaults', () => {
38
+ const c = getConfig({
39
+ repository: { owner: 'test', name: 'repo' },
40
+ canvas: { github: { ghGuard: 'link' } },
41
+ })
42
+ expect(c.repository).toEqual({ owner: 'test', name: 'repo' })
43
+ expect(c.canvas.github.ghGuard).toBe('link')
44
+ // Other defaults preserved
45
+ expect(c.canvas.github.embedBehavior).toBe('link-preview')
46
+ expect(c.canvas.pasteRules).toEqual(builtinPasteRules)
47
+ })
48
+
49
+ it('replaces arrays instead of concatenating', () => {
50
+ const customRules = [{ id: 'custom', match: 'https://example.com', widget: 'link-preview' }]
51
+ const c = getConfig({ canvas: { pasteRules: customRules } })
52
+ expect(c.canvas.pasteRules).toEqual(customRules)
53
+ expect(c.canvas.pasteRules).not.toContainEqual(builtinPasteRules[0])
54
+ })
55
+
56
+ it('preserves existing keys not in defaults', () => {
57
+ const c = getConfig({ devDomain: 'my-project', featureFlags: { 'show-banner': true } })
58
+ expect(c.devDomain).toBe('my-project')
59
+ expect(c.featureFlags['show-banner']).toBe(true)
60
+ })
61
+
62
+ it('does not mutate configDefaults', () => {
63
+ const before = JSON.stringify(configDefaults)
64
+ getConfig({ canvas: { github: { ghGuard: 'off' } } })
65
+ expect(JSON.stringify(configDefaults)).toBe(before)
66
+ })
67
+ })
68
+ })
package/src/index.js CHANGED
@@ -18,6 +18,8 @@ export { listPrototypes, getPrototypeMetadata } from './loader.js'
18
18
  export { listFolders, getFolderMetadata } from './loader.js'
19
19
  // Canvas data
20
20
  export { listCanvases, getCanvasData } from './loader.js'
21
+ // Story data
22
+ export { listStories, getStoryData } from './loader.js'
21
23
  // Deprecated scene aliases
22
24
  export { loadScene, listScenes, sceneExists } from './loader.js'
23
25
 
@@ -87,3 +89,6 @@ export { TOOL_STATES, initToolbarToolStates, setToolbarToolState, getToolbarTool
87
89
 
88
90
  // Comments system
89
91
  export { initCommentsConfig, getCommentsConfig, isCommentsEnabled } from './comments/config.js'
92
+
93
+ // Canvas config (paste rules, canvas-level overrides)
94
+ export { initCanvasConfig, getPasteRules } from './canvasConfig.js'
@@ -227,12 +227,14 @@ export async function createInspectorHighlighter() {
227
227
  * @param {object} options
228
228
  * @param {string} [options.lang] - Language identifier
229
229
  * @param {string} [options.theme] - Ignored (theme resolved from config)
230
+ * @param {boolean} [options.lineNumbers] - Show inline line numbers (default: true)
230
231
  * @param {Array<{ start: { line: number }, end: { line: number }, properties: { class: string } }>} [options.decorations]
231
232
  * @returns {string} HTML string with highlighted code
232
233
  */
233
234
  codeToHtml(code, options = {}) {
234
235
  const lang = options.lang || 'javascript'
235
236
  const decorations = options.decorations || []
237
+ const showLineNumbers = options.lineNumbers !== false
236
238
  const colors = getColors()
237
239
 
238
240
  let highlighted
@@ -255,13 +257,19 @@ export async function createInspectorHighlighter() {
255
257
  }
256
258
  }
257
259
 
260
+ const lineNumWidth = String(lines.length).length
261
+ const gutterColor = colors.comment || colors.headerFg || '#636e7b'
262
+
258
263
  const wrappedLines = lines.map((line, i) => {
259
264
  const classes = ['line']
260
265
  if (highlightedLines.has(i)) classes.push('highlighted-line')
261
- return `<span class="${classes.join(' ')}">${line}</span>`
266
+ const numSpan = showLineNumbers
267
+ ? `<span class="line-number" style="color:${gutterColor};user-select:none;opacity:0.5;display:inline-block;width:${lineNumWidth}ch;text-align:right;margin-right:1.5ch">${String(i + 1).padStart(lineNumWidth)}</span>`
268
+ : ''
269
+ return `<span class="${classes.join(' ')}">${numSpan}${line}</span>`
262
270
  }).join('\n')
263
271
 
264
- return `<pre style="background:${colors.bg};color:${colors.fg};margin:0;padding:0;overflow-x:auto"><code>${wrappedLines}</code></pre>`
272
+ return `<pre style="background:${colors.bg};color:${colors.fg};margin:0;padding:var(--base-size-8);overflow-x:auto"><code>${wrappedLines}</code></pre>`
265
273
  },
266
274
  }
267
275
  }
@@ -17,7 +17,7 @@
17
17
  // that breaks when this source is consumed from node_modules.
18
18
  if (typeof CSS !== 'undefined' && 'paintWorklet' in CSS) {
19
19
  try {
20
- const worklet = `class P{static get inputProperties(){return["--sb--smooth-corners"]}superellipse(a,b,nX=4,nY){if(Number.isNaN(nX))nX=4;if(typeof nY==="undefined"||Number.isNaN(nY))nY=nX;if(nX>100)nX=100;if(nY>100)nY=100;if(nX<1e-11)nX=1e-11;if(nY<1e-11)nY=1e-11;const nX2=2/nX,nY2=nY?2/nY:nX2,steps=360,step=(2*Math.PI)/steps;return Array.from({length:steps},(_,i)=>{const t=i*step,cosT=Math.cos(t),sinT=Math.sin(t);return{x:Math.abs(cosT)**nX2*a*Math.sign(cosT),y:Math.abs(sinT)**nY2*b*Math.sign(sinT)}})}paint(ctx,geom,props){const[nX,nY]=props.get("--sb--smooth-corners").toString().replace(/ /g,"").split(",");const w=geom.width/2,h=geom.height/2,s=this.superellipse(w,h,parseFloat(nX),parseFloat(nY));ctx.fillStyle="#000";ctx.setTransform(1,0,0,1,w,h);ctx.beginPath();for(let i=0;i<s.length;i++){const{x,y}=s[i];i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)}ctx.closePath();ctx.fill()}}registerPaint("smooth-corners",P);`;
20
+ const worklet = `class P{static get inputProperties(){return["--sb--smooth-corners"]}superellipse(a,b,nX=4,nY){if(Number.isNaN(nX))nX=4;if(typeof nY==="undefined"||Number.isNaN(nY))nY=nX;if(nX>100)nX=100;if(nY>100)nY=100;if(nX<1e-11)nX=1e-11;if(nY<1e-11)nY=1e-11;const nX2=2/nX,nY2=nY?2/nY:nX2,steps=360,step=(2*Math.PI)/steps;return Array.from({length:steps},(_,i)=>{const t=i*step,cosT=Math.cos(t),sinT=Math.sin(t);return{x:Math.abs(cosT)**nX2*a*Math.sign(cosT),y:Math.abs(sinT)**nY2*b*Math.sign(sinT)}})}paint(ctx,geom,props){const[nX,nY]=props.get("--sb--smooth-corners").toString().replace(/ /g,"").split(",");const w=geom.width/2,h=geom.height/2,s=this.superellipse(w,h,parseFloat(nX),parseFloat(nY));ctx.fillStyle="#000";ctx.setTransform(1,0,0,1,w,h);ctx.beginPath();for(let i=0;i<s.length;i++){const{x,y}=s[i];i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)}ctx.closePath();ctx.fill()}}try{registerPaint("smooth-corners",P)}catch(e){}`;
21
21
  const blob = new Blob([worklet], { type: 'application/javascript' });
22
22
  CSS.paintWorklet.addModule(URL.createObjectURL(blob));
23
23
  } catch {}
package/src/loader.js CHANGED
@@ -30,13 +30,13 @@ function deepMerge(target, source) {
30
30
  * Module-level data index, seeded by init().
31
31
  * Shape: { flows: {}, objects: {}, records: {} }
32
32
  */
33
- let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {}, folders: {}, canvases: {} }
33
+ let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {}, folders: {}, canvases: {}, stories: {} }
34
34
 
35
35
  /**
36
36
  * Seed the data index. Call once at app startup before any load functions.
37
37
  * The Vite data plugin calls this automatically via the generated virtual module.
38
38
  *
39
- * @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object, folders?: object, canvases?: object }} index
39
+ * @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object, folders?: object, canvases?: object, stories?: object }} index
40
40
  */
41
41
  export function init(index) {
42
42
  if (!index || typeof index !== 'object') {
@@ -49,6 +49,7 @@ export function init(index) {
49
49
  prototypes: index.prototypes || {},
50
50
  folders: index.folders || {},
51
51
  canvases: index.canvases || {},
52
+ stories: index.stories || {},
52
53
  }
53
54
  }
54
55
 
@@ -380,4 +381,22 @@ export function getCanvasData(name) {
380
381
  return dataIndex.canvases[name] ?? null
381
382
  }
382
383
 
384
+ /**
385
+ * Returns the names of all registered stories.
386
+ * @returns {string[]}
387
+ */
388
+ export function listStories() {
389
+ return Object.keys(dataIndex.stories)
390
+ }
391
+
392
+ /**
393
+ * Returns story data by name.
394
+ * Story entries include `_storyModule` (path) and `_storyImport` (dynamic import function).
395
+ * @param {string} name - Story name (e.g. "button-patterns")
396
+ * @returns {object|null} Story data with import function, or null
397
+ */
398
+ export function getStoryData(name) {
399
+ return dataIndex.stories[name] ?? null
400
+ }
401
+
383
402
  export { deepMerge }
@@ -1,4 +1,4 @@
1
- import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName, resolveObjectName, listFolders, getFolderMetadata } from './loader.js'
1
+ import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName, resolveObjectName, listFolders, getFolderMetadata, listStories, getStoryData } from './loader.js'
2
2
 
3
3
  const makeIndex = () => ({
4
4
  flows: {
@@ -535,3 +535,65 @@ describe('getFolderMetadata', () => {
535
535
  expect(meta).toEqual({ meta: { title: 'My Folder', description: 'A folder' } })
536
536
  })
537
537
  })
538
+
539
+ describe('listStories', () => {
540
+ it('returns empty array when no stories are indexed', () => {
541
+ init(makeIndex())
542
+ expect(listStories()).toEqual([])
543
+ })
544
+
545
+ it('returns story names when stories are indexed', () => {
546
+ init({
547
+ ...makeIndex(),
548
+ stories: {
549
+ 'button-patterns': { _storyModule: '/src/button-patterns.story.jsx' },
550
+ 'card': { _storyModule: '/src/card.story.jsx' },
551
+ },
552
+ })
553
+ expect(listStories()).toEqual(['button-patterns', 'card'])
554
+ })
555
+ })
556
+
557
+ describe('getStoryData', () => {
558
+ it('returns null for unknown story', () => {
559
+ init(makeIndex())
560
+ expect(getStoryData('nonexistent')).toBeNull()
561
+ })
562
+
563
+ it('returns story data when story exists', () => {
564
+ const mockImport = vi.fn()
565
+ init({
566
+ ...makeIndex(),
567
+ stories: {
568
+ 'button-patterns': {
569
+ _storyModule: '/src/button-patterns.story.jsx',
570
+ _storyImport: mockImport,
571
+ },
572
+ },
573
+ })
574
+ const story = getStoryData('button-patterns')
575
+ expect(story).toBeTruthy()
576
+ expect(story._storyModule).toBe('/src/button-patterns.story.jsx')
577
+ expect(story._storyImport).toBe(mockImport)
578
+ })
579
+ })
580
+
581
+ describe('init with stories', () => {
582
+ it('defaults stories to empty object when not provided', () => {
583
+ init({ flows: {}, objects: {}, records: {} })
584
+ expect(listStories()).toEqual([])
585
+ })
586
+
587
+ it('stores stories when provided', () => {
588
+ init({
589
+ flows: {},
590
+ objects: {},
591
+ records: {},
592
+ stories: {
593
+ test: { _storyModule: '/test.story.jsx' },
594
+ },
595
+ })
596
+ expect(listStories()).toEqual(['test'])
597
+ expect(getStoryData('test')).toBeTruthy()
598
+ })
599
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Mobile viewport detection — reactive store for compact viewport state.
3
+ *
4
+ * Uses window.matchMedia for efficient, debounce-free detection.
5
+ * Threshold: 500px (matches the mobile breakpoint for toolbar compacting).
6
+ */
7
+
8
+ const MOBILE_QUERY = '(max-width: 499px)'
9
+
10
+ /** @type {boolean} */
11
+ let _isMobile = false
12
+
13
+ /** @type {Set<(mobile: boolean) => void>} */
14
+ const _listeners = new Set()
15
+
16
+ /** @type {MediaQueryList | null} */
17
+ let _mql = null
18
+
19
+ function _handleChange(e) {
20
+ _isMobile = e.matches
21
+ for (const cb of _listeners) cb(_isMobile)
22
+ }
23
+
24
+ // Initialize on load (SSR-safe)
25
+ if (typeof window !== 'undefined') {
26
+ _mql = window.matchMedia(MOBILE_QUERY)
27
+ _isMobile = _mql.matches
28
+ _mql.addEventListener('change', _handleChange)
29
+ }
30
+
31
+ /**
32
+ * Returns true when the viewport is narrower than 500px.
33
+ * @returns {boolean}
34
+ */
35
+ export function isMobile() {
36
+ return _isMobile
37
+ }
38
+
39
+ /**
40
+ * Subscribe to mobile state changes.
41
+ * @param {(mobile: boolean) => void} callback
42
+ * @returns {() => void} unsubscribe
43
+ */
44
+ export function subscribeToMobile(callback) {
45
+ _listeners.add(callback)
46
+ return () => _listeners.delete(callback)
47
+ }
48
+
49
+ /**
50
+ * Check if the device has a coarse pointer (touch device).
51
+ * Useful for PWA install prompts — avoids showing on narrow desktop windows.
52
+ * @returns {boolean}
53
+ */
54
+ export function isTouchDevice() {
55
+ if (typeof window === 'undefined') return false
56
+ return window.matchMedia('(pointer: coarse)').matches
57
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ describe('mobileViewport', () => {
4
+ let matchMediaListeners = []
5
+ let matchMediaMatches = false
6
+
7
+ beforeEach(() => {
8
+ matchMediaListeners = []
9
+ matchMediaMatches = false
10
+
11
+ vi.stubGlobal('matchMedia', vi.fn((query) => {
12
+ const mql = {
13
+ matches: matchMediaMatches,
14
+ media: query,
15
+ addEventListener: vi.fn((event, cb) => {
16
+ matchMediaListeners.push(cb)
17
+ }),
18
+ removeEventListener: vi.fn(),
19
+ }
20
+ return mql
21
+ }))
22
+ })
23
+
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals()
26
+ vi.resetModules()
27
+ })
28
+
29
+ it('isMobile returns false for wide viewports', async () => {
30
+ matchMediaMatches = false
31
+ const { isMobile } = await import('./mobileViewport.js')
32
+ expect(isMobile()).toBe(false)
33
+ })
34
+
35
+ it('isMobile returns true for narrow viewports', async () => {
36
+ matchMediaMatches = true
37
+ const { isMobile } = await import('./mobileViewport.js')
38
+ expect(isMobile()).toBe(true)
39
+ })
40
+
41
+ it('subscribeToMobile notifies on change', async () => {
42
+ matchMediaMatches = false
43
+ const { isMobile, subscribeToMobile } = await import('./mobileViewport.js')
44
+
45
+ const cb = vi.fn()
46
+ const unsub = subscribeToMobile(cb)
47
+
48
+ // Simulate matchMedia change event
49
+ const changeHandler = matchMediaListeners[0]
50
+ expect(changeHandler).toBeDefined()
51
+ changeHandler({ matches: true })
52
+
53
+ expect(cb).toHaveBeenCalledWith(true)
54
+ expect(isMobile()).toBe(true)
55
+
56
+ unsub()
57
+ changeHandler({ matches: false })
58
+ // Should not be called after unsubscribe
59
+ expect(cb).toHaveBeenCalledTimes(1)
60
+ })
61
+
62
+ it('isTouchDevice checks pointer: coarse', async () => {
63
+ const { isTouchDevice } = await import('./mobileViewport.js')
64
+ // Our mock returns matchMediaMatches for all queries
65
+ matchMediaMatches = false
66
+ expect(isTouchDevice()).toBe(false)
67
+ })
68
+ })
@@ -17,6 +17,7 @@ import { initCommentsConfig, isCommentsEnabled } from './comments/config.js'
17
17
  import { initFeatureFlags } from './featureFlags.js'
18
18
  import { initPlugins } from './plugins.js'
19
19
  import { initUIConfig } from './uiConfig.js'
20
+ import { initCanvasConfig } from './canvasConfig.js'
20
21
  import { initToolbarConfig } from './toolbarConfigStore.js'
21
22
 
22
23
  let _mounted = false
@@ -156,6 +157,10 @@ export async function mountStoryboardCore(config = {}, options = {}) {
156
157
  initUIConfig(config.ui)
157
158
  }
158
159
 
160
+ if (config.canvas) {
161
+ initCanvasConfig(config.canvas)
162
+ }
163
+
159
164
  // Initialize comments config (framework-agnostic)
160
165
  if (config.comments) {
161
166
  initCommentsConfig(config, { basePath })
@@ -220,6 +225,49 @@ export async function mountStoryboardCore(config = {}, options = {}) {
220
225
  history.replaceState = (...args) => { origReplace(...args); broadcastNavigation() }
221
226
  window.addEventListener('popstate', broadcastNavigation)
222
227
  window.addEventListener('hashchange', broadcastNavigation)
228
+
229
+ // Signal render-ready after app settles.
230
+ // Uses a 3s delay as a fallback — React components, data loading, and
231
+ // animations need time. Individual page types (StoryPage) may also
232
+ // send their own snapshot-ready after they finish rendering.
233
+ Promise.all([
234
+ document.fonts?.ready || Promise.resolve(),
235
+ new Promise(r => setTimeout(r, 3000)),
236
+ ]).then(() => {
237
+ window.parent.postMessage({ type: 'storyboard:embed:snapshot-ready' }, '*')
238
+ })
239
+
240
+ // Listen for snapshot capture requests from the parent canvas
241
+ window.addEventListener('message', async (e) => {
242
+ if (e.data?.type !== 'storyboard:embed:capture') return
243
+ const { requestId } = e.data
244
+ try {
245
+ const { toBlob } = await import('html-to-image')
246
+ const blob = await toBlob(document.body, {
247
+ type: 'image/webp',
248
+ quality: 0.85,
249
+ width: document.documentElement.clientWidth,
250
+ height: document.documentElement.clientHeight,
251
+ pixelRatio: 2,
252
+ })
253
+ if (!blob) throw new Error('Capture returned empty blob')
254
+ const reader = new FileReader()
255
+ reader.onload = () => {
256
+ window.parent.postMessage({
257
+ type: 'storyboard:embed:snapshot',
258
+ requestId,
259
+ dataUrl: reader.result,
260
+ }, '*')
261
+ }
262
+ reader.readAsDataURL(blob)
263
+ } catch (err) {
264
+ window.parent.postMessage({
265
+ type: 'storyboard:embed:snapshot',
266
+ requestId,
267
+ error: err.message,
268
+ }, '*')
269
+ }
270
+ })
223
271
  }
224
272
  return
225
273
  }
@@ -253,21 +301,21 @@ export async function mountStoryboardCore(config = {}, options = {}) {
253
301
  * success message is lost. This shows a temporary toast with the link.
254
302
  */
255
303
  function showPendingNotification(basePath) {
256
- const KEYS = ['sb-canvas-created', 'sb-prototype-created', 'sb-flow-created']
304
+ const KEYS = ['sb-canvas-created', 'sb-prototype-created', 'sb-flow-created', 'sb-story-created']
257
305
  for (const key of KEYS) {
258
306
  try {
259
307
  const raw = sessionStorage.getItem(key)
260
308
  if (!raw) continue
261
309
  sessionStorage.removeItem(key)
262
- const { success: message, route } = JSON.parse(raw)
310
+ const { success: message, route, path: filePath } = JSON.parse(raw)
263
311
  if (!message) continue
264
- showToast(message, route, basePath)
312
+ showToast(message, route, basePath, filePath)
265
313
  return
266
314
  } catch { /* ignore malformed session entry */ }
267
315
  }
268
316
  }
269
317
 
270
- function showToast(message, route, basePath) {
318
+ function showToast(message, route, basePath, filePath) {
271
319
  const toast = document.createElement('div')
272
320
  Object.assign(toast.style, {
273
321
  position: 'fixed',
@@ -287,12 +335,18 @@ function showToast(message, route, basePath) {
287
335
  gap: '0.25rem',
288
336
  opacity: '0',
289
337
  transition: 'opacity 0.15s ease',
290
- maxWidth: '280px',
338
+ maxWidth: '320px',
291
339
  })
292
340
 
293
341
  const href = route?.startsWith('/') ? (basePath.replace(/\/$/, '') + route) : route
294
- toast.innerHTML = `<span style="font-weight:500">✓ ${message.replace(/</g, '&lt;')}</span>`
295
- + (href ? `<a href="${href}" style="color:var(--sb--color-primary, #0969da);text-decoration:underline;font-size:0.8125rem">Open canvas</a>` : '')
342
+ let html = `<span style="font-weight:500">✓ ${message.replace(/</g, '&lt;')}</span>`
343
+ if (href) {
344
+ html += `<a href="${href}" style="color:var(--sb--color-primary, #0969da);text-decoration:underline;font-size:0.8125rem">Open canvas</a>`
345
+ }
346
+ if (filePath) {
347
+ html += `<span style="font-size:0.75rem;color:var(--sb--color-muted, #64748b)">To edit your component, go to <code style="background:var(--sb--color-muted-bg, #f1f5f9);padding:1px 4px;border-radius:3px;font-size:0.75rem">${filePath.replace(/</g, '&lt;')}</code></span>`
348
+ }
349
+ toast.innerHTML = html
296
350
 
297
351
  document.body.appendChild(toast)
298
352
  requestAnimationFrame(() => { toast.style.opacity = '1' })
@@ -0,0 +1,23 @@
1
+ {
2
+ "watch": [
3
+ {
4
+ "path": "src/prototypes",
5
+ "extensions": [".jsx", ".tsx", ".mdx"],
6
+ "type": "prototype"
7
+ },
8
+ {
9
+ "path": "src/canvas",
10
+ "extensions": [".canvas.jsonl"],
11
+ "type": "canvas"
12
+ }
13
+ ],
14
+ "exclude": {
15
+ "filePrefixes": ["_"],
16
+ "directories": ["node_modules", ".git", "dist", "images", ".worktrees"]
17
+ },
18
+ "debounceMs": 500,
19
+ "autocommit": {
20
+ "enabled": true,
21
+ "prefix": "[storyboard-autofix]"
22
+ }
23
+ }