@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- 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
|
-
|
|
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:
|
|
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 }
|
package/src/loader.test.js
CHANGED
|
@@ -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: '
|
|
338
|
+
maxWidth: '320px',
|
|
291
339
|
})
|
|
292
340
|
|
|
293
341
|
const href = route?.startsWith('/') ? (basePath.replace(/\/$/, '') + route) : route
|
|
294
|
-
|
|
295
|
-
|
|
342
|
+
let html = `<span style="font-weight:500">✓ ${message.replace(/</g, '<')}</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, '<')}</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
|
+
}
|