@dfosco/storyboard 0.9.5 → 0.10.0
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/commandpalette.config.json +6 -0
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +16450 -12737
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +66 -2
- package/scaffold/src/_prototype.jsx +9 -1
- package/src/core/artifact/schemas/component.schema.json +55 -0
- package/src/core/artifact/schemas/prototype.schema.json +55 -0
- package/src/core/canvas/materializer.js +120 -105
- package/src/core/canvas/materializer.test.js +172 -1
- package/src/core/canvas/server.js +156 -11
- package/src/core/cli/canvasConnector.js +51 -2
- package/src/core/cli/configureRepository.js +164 -0
- package/src/core/cli/dev.js +14 -23
- package/src/core/cli/file.js +333 -0
- package/src/core/cli/gh.js +101 -0
- package/src/core/cli/index.js +4 -0
- package/src/core/cli/repoConfig.js +109 -0
- package/src/core/cli/repoConfig.test.js +119 -0
- package/src/core/cli/setup.js +15 -0
- package/src/core/cli/workleaf.js +3 -3
- package/src/core/comments/ui/mount.js +27 -0
- package/src/core/file/__tests__/server.test.js +604 -0
- package/src/core/file/__tests__/tree.test.js +190 -0
- package/src/core/file/server.js +519 -0
- package/src/core/file/tree.js +151 -0
- package/src/core/file-watcher/config.json +28 -0
- package/src/core/file-watcher/watcher.js +891 -0
- package/src/core/{rename-watcher → file-watcher}/watcher.test.js +142 -1
- package/src/core/index.js +4 -1
- package/src/core/knobs/coerce.js +93 -0
- package/src/core/knobs/coerce.test.js +51 -0
- package/src/core/knobs/discovery.js +109 -0
- package/src/core/knobs/discovery.test.js +69 -0
- package/src/core/knobs/index.js +18 -0
- package/src/core/knobs/keys.js +26 -0
- package/src/core/knobs/keys.test.js +28 -0
- package/src/core/knobs/writer.js +117 -0
- package/src/core/knobs/writer.test.js +113 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +10 -2
- package/src/core/mountStoryboardCore.js +1 -0
- package/src/core/rename-watcher/watcher.js +3 -824
- package/src/core/session/session.js +31 -3
- package/src/core/session/session.test.js +31 -75
- package/src/core/stores/canvasConfig.js +31 -1
- package/src/core/stores/canvasConfig.test.js +50 -1
- package/src/core/stores/canvasInteractionStore.js +29 -9
- package/src/core/stores/canvasInteractionStore.test.js +37 -11
- package/src/core/stores/configSchema.js +58 -0
- package/src/core/stores/configSchema.test.js +7 -0
- package/src/core/stores/themeBootstrap.js +18 -1
- package/src/core/stores/themeBootstrap.test.js +17 -6
- package/src/core/stores/uiConfig.js +2 -1
- package/src/core/styles/tailwind.css +2 -0
- package/src/core/tools/handlers/knobs.js +14 -0
- package/src/core/tools/registry.js +1 -0
- package/src/core/ui/CanvasCreateMenu.jsx +9 -1
- package/src/core/ui/CanvasSnap.jsx +14 -7
- package/src/core/ui/CanvasZoomToFit.jsx +11 -8
- package/src/core/ui/CoreUIBar.jsx +70 -3
- package/src/core/ui/KnobsPanel.jsx +320 -0
- package/src/core/ui/KnobsPanel.module.css +118 -0
- package/src/core/ui/KnobsPanel.test.jsx +135 -0
- package/src/core/ui/knobsPanelState.js +25 -0
- package/src/core/vite/server-plugin.js +4 -0
- package/src/core/workleaf/create.js +62 -12
- package/src/core/workleaf/nameGenerator.js +133 -0
- package/src/core/workleaf/nameGenerator.test.js +99 -0
- package/src/internals/BaseUiForm/BaseUiForm.jsx +85 -3
- package/src/internals/BaseUiForm/BaseUiForm.module.css +234 -2
- package/src/internals/BranchBar/BranchBar.jsx +3 -1
- package/src/internals/CommandPalette/CommandPalette.jsx +55 -25
- package/src/internals/Knobs/KnobsForm.jsx +633 -0
- package/src/internals/Knobs/KnobsForm.module.css +162 -0
- package/src/internals/Knobs/KnobsForm.test.jsx +107 -0
- package/src/internals/Knobs/index.js +1 -0
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +192 -0
- package/src/internals/canvas/CanvasPage.jsx +629 -197
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/internals/canvas/CanvasToolbar.jsx +6 -0
- package/src/internals/canvas/ConnectorLayer.jsx +54 -12
- package/src/internals/canvas/ConnectorLayer.module.css +67 -0
- package/src/internals/canvas/PageSelector.jsx +187 -202
- package/src/internals/canvas/PageSelector.module.css +12 -6
- package/src/internals/canvas/canvasApi.js +21 -2
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +9 -2
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +8 -1
- package/src/internals/canvas/widgets/FileWidget/CodeEditor.jsx +304 -0
- package/src/internals/canvas/widgets/FileWidget/CodeEditor.module.css +57 -0
- package/src/internals/canvas/widgets/FileWidget/DeleteConfirmController.jsx +62 -0
- package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.jsx +46 -0
- package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.module.css +48 -0
- package/src/internals/canvas/widgets/FileWidget/FilePicker.jsx +309 -0
- package/src/internals/canvas/widgets/FileWidget/FilePicker.module.css +170 -0
- package/src/internals/canvas/widgets/FileWidget/FilePickerController.jsx +75 -0
- package/src/internals/canvas/widgets/FileWidget/FileSecondaryPane.jsx +167 -0
- package/src/internals/canvas/widgets/FileWidget/FileWidget.jsx +658 -0
- package/src/internals/canvas/widgets/FileWidget/FileWidget.module.css +359 -0
- package/src/internals/canvas/widgets/FileWidget/MdxRenderer.jsx +153 -0
- package/src/internals/canvas/widgets/FileWidget/MdxRenderer.module.css +81 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/CodeEditor.test.jsx +235 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/DeleteConfirmDialog.test.jsx +51 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/FilePicker.test.jsx +329 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/FileWidget.test.jsx +525 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/MdxRenderer.test.jsx +81 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/fileContentStore.test.js +404 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/fileFlavor.test.js +287 -0
- package/src/internals/canvas/widgets/FileWidget/fileContentStore.js +328 -0
- package/src/internals/canvas/widgets/FileWidget/fileFlavor.js +250 -0
- package/src/internals/canvas/widgets/KnobsWidget.jsx +229 -0
- package/src/internals/canvas/widgets/KnobsWidget.module.css +144 -0
- package/src/internals/canvas/widgets/KnobsWidget.noreload.test.jsx +215 -0
- package/src/internals/canvas/widgets/KnobsWidget.test.jsx +237 -0
- package/src/internals/canvas/widgets/MarkdownBlock.jsx +32 -152
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +17 -328
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +20 -2
- package/src/internals/canvas/widgets/StickyNote.jsx +43 -34
- package/src/internals/canvas/widgets/StickyNote.module.css +123 -4
- package/src/internals/canvas/widgets/StickyNote.test.jsx +95 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +33 -6
- package/src/internals/canvas/widgets/StoryWidget.jsx +18 -0
- package/src/internals/canvas/widgets/TerminalWidget.jsx +46 -0
- package/src/internals/canvas/widgets/expandUtils.js +63 -4
- package/src/internals/canvas/widgets/iframeRefContract.test.jsx +102 -0
- package/src/internals/canvas/widgets/iframeSrcCompare.js +38 -0
- package/src/internals/canvas/widgets/iframeSrcCompare.test.js +65 -0
- package/src/internals/canvas/widgets/index.js +4 -2
- package/src/internals/canvas/widgets/knobsTargetResolver.js +215 -0
- package/src/internals/canvas/widgets/knobsTargetResolver.test.js +142 -0
- package/src/internals/canvas/widgets/markdown/MarkdownEditor.jsx +175 -0
- package/src/internals/canvas/widgets/markdown/MarkdownEditor.module.css +11 -0
- package/src/internals/canvas/widgets/markdown/MarkdownView.test.jsx +32 -0
- package/src/internals/canvas/widgets/markdown/markdownClass.js +16 -0
- package/src/internals/canvas/widgets/markdown/markdownContent.module.css +230 -0
- package/src/internals/canvas/widgets/markdown/markdownRender.js +53 -0
- package/src/internals/canvas/widgets/storyPath.js +31 -0
- package/src/internals/canvas/widgets/storyPath.test.js +59 -0
- package/src/internals/canvas/widgets/widgetConfig.js +92 -2
- package/src/internals/canvas/widgets/widgetConfig.knobs.test.js +42 -0
- package/src/internals/canvas/widgets/widgetConfig.merged.test.js +27 -0
- package/src/internals/canvas/widgets/widgetConfig.test.js +33 -2
- package/src/internals/canvas/widgets/widgetIcons.jsx +6 -1
- package/src/internals/hooks/useKnob.js +97 -0
- package/src/internals/hooks/useKnob.test.js +133 -0
- package/src/internals/index.js +2 -0
- package/src/internals/story/ComponentSetPage.jsx +21 -9
- package/src/internals/vite/data-plugin.js +45 -7
- package/src/internals/vite/data-plugin.test.js +3 -3
- package/storyboard.config.json +6 -0
- package/toolbar.config.json +15 -2
- package/widgets.config.json +235 -133
- package/src/core/ui/CanvasSnap.css +0 -27
- package/src/core/ui/CanvasZoomToFit.css +0 -18
- package/src/internals/canvas/widgets/TilesWidget.jsx +0 -300
- package/src/internals/canvas/widgets/TilesWidget.module.css +0 -133
- package/src/internals/canvas/widgets/tilePool.js +0 -23
- package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only knob hook.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the current prototype/route, reads the corresponding knob hash
|
|
5
|
+
* value through useOverride, coerces it by schema, and falls back to defaults.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useContext } from 'react'
|
|
9
|
+
import { StoryboardContext } from '../StoryboardContext.js'
|
|
10
|
+
import { useOverride } from './useOverride.js'
|
|
11
|
+
import {
|
|
12
|
+
buildKnobKey,
|
|
13
|
+
coerceByType,
|
|
14
|
+
getPrototypeKnobs,
|
|
15
|
+
normalizeScope,
|
|
16
|
+
resolveKnobDef,
|
|
17
|
+
} from '../../core/knobs/index.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect the active prototype and route scope from the current URL.
|
|
21
|
+
*
|
|
22
|
+
* Handles the dev-only `/prototypes.html/<proto>/...` iframe-isolation
|
|
23
|
+
* prefix (`packages/storyboard/src/internals/canvas/widgets/PrototypeEmbed.jsx`)
|
|
24
|
+
* by stripping it before parsing — otherwise `prototypes.html` ends up as
|
|
25
|
+
* the prototype name and the real prototype is treated as a route segment.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} [pathname]
|
|
28
|
+
* @param {string} [basePath]
|
|
29
|
+
* @returns {{ prototypeName: string|null, route: string|undefined }}
|
|
30
|
+
*/
|
|
31
|
+
export function detectKnobRoute(
|
|
32
|
+
pathname = window.location.pathname,
|
|
33
|
+
basePath = import.meta.env?.BASE_URL || '/',
|
|
34
|
+
) {
|
|
35
|
+
let path = pathname
|
|
36
|
+
const base = basePath.replace(/\/+$/, '')
|
|
37
|
+
if (base && base !== '/' && path.startsWith(base)) {
|
|
38
|
+
path = path.slice(base.length) || '/'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Strip the prototypes.html isolation prefix (dev iframe path).
|
|
42
|
+
path = path.replace(/^\/prototypes\.html(?=\/|$)/, '') || '/'
|
|
43
|
+
|
|
44
|
+
path = path.replace(/\/+$/, '') || '/'
|
|
45
|
+
const segments = path.split('/').filter(Boolean)
|
|
46
|
+
const branchSegment = segments[0]?.startsWith('branch--') ? segments[0] : null
|
|
47
|
+
const protoIdx = branchSegment ? 1 : 0
|
|
48
|
+
const prototypeName = segments[protoIdx] || null
|
|
49
|
+
const routeSegments = prototypeName ? segments.slice(protoIdx + 1) : []
|
|
50
|
+
const route = routeSegments.length > 0 ? `/${routeSegments.join('/')}` : undefined
|
|
51
|
+
|
|
52
|
+
return { prototypeName, route }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a typed knob value from URL hash state.
|
|
57
|
+
*
|
|
58
|
+
* Behaves like `useOverride`: reads the URL hash key, re-renders on
|
|
59
|
+
* `hashchange`. When a schema is declared, coercion + default fallback
|
|
60
|
+
* apply. When no schema is discovered, returns the raw hash value as-is
|
|
61
|
+
* so prototypes always react to URL changes (even before discovery runs).
|
|
62
|
+
*
|
|
63
|
+
* @param {string} id
|
|
64
|
+
* @param {{ route?: string|null, scope?: string|null }} [options]
|
|
65
|
+
* @returns {*}
|
|
66
|
+
*/
|
|
67
|
+
export function useKnob(id, { route, scope } = {}) {
|
|
68
|
+
const context = useContext(StoryboardContext)
|
|
69
|
+
const detected = detectKnobRoute()
|
|
70
|
+
const hasExplicitRoute = route !== undefined || scope !== undefined
|
|
71
|
+
const routeValue = route !== undefined ? route : scope
|
|
72
|
+
const resolvedRoute = hasExplicitRoute ? normalizeScope(routeValue) : detected.route
|
|
73
|
+
const prototypeName = context?.prototypeName ?? detected.prototypeName
|
|
74
|
+
|
|
75
|
+
const key = buildKnobKey(id, { route: resolvedRoute })
|
|
76
|
+
const [rawValue] = useOverride(key)
|
|
77
|
+
const knobs = prototypeName ? getPrototypeKnobs(prototypeName) : []
|
|
78
|
+
const def = resolveKnobDef(id, { route: resolvedRoute, knobs })
|
|
79
|
+
|
|
80
|
+
// No schema discovered? Best-effort coerce common string forms so
|
|
81
|
+
// consumers don't have to ?? against the string "false" (truthy!).
|
|
82
|
+
// Numeric strings stay numeric. Everything else passes through as-is.
|
|
83
|
+
if (!def) {
|
|
84
|
+
if (rawValue == null) return undefined
|
|
85
|
+
if (rawValue === 'true') return true
|
|
86
|
+
if (rawValue === 'false') return false
|
|
87
|
+
const asNumber = Number(rawValue)
|
|
88
|
+
if (rawValue !== '' && !Number.isNaN(asNumber)) return asNumber
|
|
89
|
+
return rawValue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// With a schema: coerce → fall back to default when raw is missing or
|
|
93
|
+
// un-coercible. Mirrors the original "URL is invalid, use the default"
|
|
94
|
+
// semantics so consumers always get a typed value.
|
|
95
|
+
const coerced = coerceByType(rawValue, def)
|
|
96
|
+
return coerced !== undefined ? coerced : def.default
|
|
97
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createElement } from 'react'
|
|
2
|
+
import { renderHook } from '@testing-library/react'
|
|
3
|
+
import { StoryboardContext } from '../StoryboardContext.js'
|
|
4
|
+
import { initKnobs } from '../../core/knobs/index.js'
|
|
5
|
+
import { detectKnobRoute, useKnob } from './useKnob.js'
|
|
6
|
+
|
|
7
|
+
function wrapper(prototypeName = 'Signup') {
|
|
8
|
+
return function Wrapper({ children }) {
|
|
9
|
+
return createElement(
|
|
10
|
+
StoryboardContext.Provider,
|
|
11
|
+
{ value: { data: {}, error: null, loading: false, prototypeName } },
|
|
12
|
+
children,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
initKnobs()
|
|
19
|
+
window.history.pushState(null, '', '/')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('detectKnobRoute', () => {
|
|
23
|
+
it('detects prototype and route from normal and branch-prefixed paths', () => {
|
|
24
|
+
expect(detectKnobRoute('/Signup/Branding')).toEqual({
|
|
25
|
+
prototypeName: 'Signup',
|
|
26
|
+
route: '/Branding',
|
|
27
|
+
})
|
|
28
|
+
expect(detectKnobRoute('/branch--knobs-system/Signup/Branding')).toEqual({
|
|
29
|
+
prototypeName: 'Signup',
|
|
30
|
+
route: '/Branding',
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('strips the /prototypes.html iframe-isolation prefix', () => {
|
|
35
|
+
expect(detectKnobRoute('/prototypes.html/Signup')).toEqual({
|
|
36
|
+
prototypeName: 'Signup',
|
|
37
|
+
route: undefined,
|
|
38
|
+
})
|
|
39
|
+
expect(detectKnobRoute('/prototypes.html/Signup/Branding')).toEqual({
|
|
40
|
+
prototypeName: 'Signup',
|
|
41
|
+
route: '/Branding',
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('useKnob', () => {
|
|
47
|
+
it('returns a prototype-wide default when no hash value is present', () => {
|
|
48
|
+
initKnobs({
|
|
49
|
+
prototypeKnobs: {
|
|
50
|
+
Signup: [{ id: 'showAvatar', type: 'boolean', default: true }],
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
window.history.pushState(null, '', '/Signup')
|
|
54
|
+
|
|
55
|
+
const { result } = renderHook(() => useKnob('showAvatar'), {
|
|
56
|
+
wrapper: wrapper('Signup'),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(result.current).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('coerces prototype-wide hash values', () => {
|
|
63
|
+
initKnobs({
|
|
64
|
+
prototypeKnobs: {
|
|
65
|
+
Signup: [{ id: 'showAvatar', type: 'boolean', default: true }],
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
window.history.pushState(null, '', '/Signup#knobShowAvatar=false')
|
|
69
|
+
|
|
70
|
+
const { result } = renderHook(() => useKnob('showAvatar'), {
|
|
71
|
+
wrapper: wrapper('Signup'),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(result.current).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('auto-detects route-scoped knob values', () => {
|
|
78
|
+
initKnobs({
|
|
79
|
+
prototypeKnobs: {
|
|
80
|
+
Signup: [{
|
|
81
|
+
id: 'variant',
|
|
82
|
+
type: 'select',
|
|
83
|
+
scope: '/Branding',
|
|
84
|
+
options: ['small', 'large'],
|
|
85
|
+
default: 'small',
|
|
86
|
+
}],
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
window.history.pushState(null, '', '/Signup/Branding#knobBrandingVariant=large')
|
|
90
|
+
|
|
91
|
+
const { result } = renderHook(() => useKnob('variant'), {
|
|
92
|
+
wrapper: wrapper('Signup'),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(result.current).toBe('large')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('treats scope as a route alias', () => {
|
|
99
|
+
initKnobs({
|
|
100
|
+
prototypeKnobs: {
|
|
101
|
+
Signup: [{
|
|
102
|
+
id: 'variant',
|
|
103
|
+
type: 'select',
|
|
104
|
+
scope: '/Branding',
|
|
105
|
+
options: ['small', 'large'],
|
|
106
|
+
default: 'small',
|
|
107
|
+
}],
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
window.history.pushState(null, '', '/Signup#knobBrandingVariant=large')
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() => useKnob('variant', { scope: '/Branding' }), {
|
|
113
|
+
wrapper: wrapper('Signup'),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(result.current).toBe('large')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('falls back to defaults for malformed hash values', () => {
|
|
120
|
+
initKnobs({
|
|
121
|
+
prototypeKnobs: {
|
|
122
|
+
Signup: [{ id: 'showAvatar', type: 'boolean', default: true }],
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
window.history.pushState(null, '', '/Signup#knobShowAvatar=nope')
|
|
126
|
+
|
|
127
|
+
const { result } = renderHook(() => useKnob('showAvatar'), {
|
|
128
|
+
wrapper: wrapper('Signup'),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(result.current).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
})
|
package/src/internals/index.js
CHANGED
|
@@ -26,6 +26,8 @@ export { useFeatureFlag } from './hooks/useFeatureFlag.js'
|
|
|
26
26
|
export { useMode } from './hooks/useMode.js'
|
|
27
27
|
export { useThemeState, useThemeSyncTargets } from './hooks/useThemeState.js'
|
|
28
28
|
export { useConfig } from './hooks/useConfig.js'
|
|
29
|
+
export { useKnob } from './hooks/useKnob.js'
|
|
30
|
+
export { KnobsForm } from './Knobs/index.js'
|
|
29
31
|
|
|
30
32
|
// React Router integration
|
|
31
33
|
export { installHashPreserver } from './hashPreserver.js'
|
|
@@ -21,6 +21,18 @@ import styles from './ComponentSetPage.module.css'
|
|
|
21
21
|
|
|
22
22
|
const StoryWrapper = getStoryWrapper()
|
|
23
23
|
|
|
24
|
+
function parseHashSelected(hash) {
|
|
25
|
+
if (!hash) return ''
|
|
26
|
+
const trimmed = hash.startsWith('#') ? hash.slice(1) : hash
|
|
27
|
+
if (!trimmed) return ''
|
|
28
|
+
try {
|
|
29
|
+
const params = new URLSearchParams(trimmed)
|
|
30
|
+
return params.get('selected') || ''
|
|
31
|
+
} catch {
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
export default function ComponentSetPage({ name }) {
|
|
25
37
|
const location = useLocation()
|
|
26
38
|
const navigate = useNavigate()
|
|
@@ -28,7 +40,7 @@ export default function ComponentSetPage({ name }) {
|
|
|
28
40
|
|
|
29
41
|
const layout = searchParams.get('layout') || 'auto'
|
|
30
42
|
const density = searchParams.get('density') || 'comfy'
|
|
31
|
-
const selected =
|
|
43
|
+
const selected = parseHashSelected(location.hash)
|
|
32
44
|
const isEmbed = searchParams.has('_sb_embed')
|
|
33
45
|
|
|
34
46
|
// Same reload-guard policy as StoryPage / prototype embeds.
|
|
@@ -157,20 +169,20 @@ export default function ComponentSetPage({ name }) {
|
|
|
157
169
|
}, [exports, layout, density, isEmbed])
|
|
158
170
|
|
|
159
171
|
const handleSelect = useCallback((exportName) => {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
navigate(`${location.pathname}
|
|
172
|
+
const isToggleOff = exportName === selected
|
|
173
|
+
const newSelected = isToggleOff ? '' : exportName
|
|
174
|
+
const hash = newSelected ? `#selected=${encodeURIComponent(newSelected)}` : ''
|
|
175
|
+
// Preserve current search params; only flip the fragment. The parent
|
|
176
|
+
// widget keeps `selected` in the URL hash specifically so this update
|
|
177
|
+
// round-trips as a same-document fragment change — no iframe reload.
|
|
178
|
+
navigate(`${location.pathname}${location.search}${hash}`, { replace: true })
|
|
167
179
|
|
|
168
180
|
// Notify parent widget
|
|
169
181
|
if (window.parent !== window) {
|
|
170
182
|
window.parent.postMessage({
|
|
171
183
|
type: 'storyboard:component-set:select',
|
|
172
184
|
storyId: name,
|
|
173
|
-
exportName:
|
|
185
|
+
exportName: newSelected || null,
|
|
174
186
|
}, '*')
|
|
175
187
|
}
|
|
176
188
|
}, [location, navigate, name, selected])
|
|
@@ -17,7 +17,7 @@ import { createIsolationMiddleware } from '../canvas/isolation/createIsolationMi
|
|
|
17
17
|
const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
|
|
18
18
|
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
19
19
|
|
|
20
|
-
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
|
|
20
|
+
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder,component}.{json,jsonc}'
|
|
21
21
|
const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
|
|
22
22
|
const CANVAS_META_GLOB_PATTERN = '**/*.meta.json'
|
|
23
23
|
const STORY_GLOB_PATTERN = '**/*.story.{jsx,tsx}'
|
|
@@ -171,7 +171,7 @@ function parseDataFile(filePath, opts = {}) {
|
|
|
171
171
|
return { name, suffix: 'story', ext: storyMatch[2], inferredRoute, isPrivate }
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
|
|
174
|
+
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder|component)\.(jsonc?)$/)
|
|
175
175
|
if (!match) return null
|
|
176
176
|
|
|
177
177
|
// Skip _-prefixed files (drafts/internal — never visible)
|
|
@@ -210,6 +210,12 @@ function parseDataFile(filePath, opts = {}) {
|
|
|
210
210
|
return { name, suffix, ext: match[3], folder: folderName, isPrivate }
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// Component metadata files are keyed by their component config basename.
|
|
214
|
+
if (suffix === 'component') {
|
|
215
|
+
const isPrivate = normalized.split('/').includes('drafts')
|
|
216
|
+
return { name, suffix, ext: match[3], isPrivate }
|
|
217
|
+
}
|
|
218
|
+
|
|
213
219
|
// Scope flows, records, and objects inside src/prototypes/{Name}/ with a prefix
|
|
214
220
|
// (skip .folder/ and `drafts/` segments when determining prototype name).
|
|
215
221
|
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:(?:[^/]+\.folder|drafts)\/)*([^/]+)\//)
|
|
@@ -350,7 +356,7 @@ function buildIndex(root, opts = {}) {
|
|
|
350
356
|
}
|
|
351
357
|
}
|
|
352
358
|
|
|
353
|
-
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {}, 'canvas-meta': {}, story: {} }
|
|
359
|
+
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, component: {}, canvas: {}, 'canvas-meta': {}, story: {} }
|
|
354
360
|
const seen = {} // "name.suffix" or "id.suffix" → absolute path (for duplicate detection)
|
|
355
361
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
356
362
|
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
@@ -857,12 +863,26 @@ function findOverlappingKeys(a, b, prefix = '') {
|
|
|
857
863
|
return overlaps
|
|
858
864
|
}
|
|
859
865
|
|
|
866
|
+
function extractPrototypeKnobs(parsed) {
|
|
867
|
+
if (Array.isArray(parsed?.meta?.knobs)) return parsed.meta.knobs
|
|
868
|
+
if (Array.isArray(parsed?.knobs)) return parsed.knobs
|
|
869
|
+
return []
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function extractComponentKnobs(parsed) {
|
|
873
|
+
if (Array.isArray(parsed?.knobs)) return parsed.knobs
|
|
874
|
+
if (Array.isArray(parsed?.meta?.knobs)) return parsed.meta.knobs
|
|
875
|
+
return []
|
|
876
|
+
}
|
|
877
|
+
|
|
860
878
|
function buildDiscoveryResult({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes, privateBySuffix = {} }, root) {
|
|
861
879
|
const discovery = {
|
|
862
880
|
flows: {},
|
|
863
881
|
objects: {},
|
|
864
882
|
records: {},
|
|
865
883
|
prototypes: {},
|
|
884
|
+
prototypeKnobs: {},
|
|
885
|
+
componentKnobs: {},
|
|
866
886
|
folders: {},
|
|
867
887
|
canvases: {},
|
|
868
888
|
canvasAliases: canvasAliases || {},
|
|
@@ -1027,10 +1047,21 @@ function buildDiscoveryResult({ index, protoFolders, flowRoutes, canvasRoutes, c
|
|
|
1027
1047
|
parsed = { ...parsed, _isPrivate: true }
|
|
1028
1048
|
}
|
|
1029
1049
|
|
|
1050
|
+
if (suffix === 'prototype') {
|
|
1051
|
+
discovery.prototypeKnobs[name] = extractPrototypeKnobs(parsed)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1030
1054
|
discovery[resultKeys[suffix]][name] = parsed
|
|
1031
1055
|
}
|
|
1032
1056
|
}
|
|
1033
1057
|
|
|
1058
|
+
for (const [name, absPath] of Object.entries(index.component || {})) {
|
|
1059
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
1060
|
+
let parsed = parseJsonc(raw)
|
|
1061
|
+
parsed = resolveTemplateVars(parsed, computeTemplateVars(absPath, root))
|
|
1062
|
+
discovery.componentKnobs[name] = extractComponentKnobs(parsed)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1034
1065
|
// Generate story entries (code modules with dynamic imports, not JSON data)
|
|
1035
1066
|
for (const [name, absPath] of Object.entries(index.story || {})) {
|
|
1036
1067
|
const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
|
|
@@ -1088,8 +1119,11 @@ function generateModule(buildResult, root) {
|
|
|
1088
1119
|
storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
|
|
1089
1120
|
}
|
|
1090
1121
|
|
|
1091
|
-
const imports = [`import { init } from '@dfosco/storyboard/core'`]
|
|
1092
|
-
const initCalls = [
|
|
1122
|
+
const imports = [`import { init, initKnobs } from '@dfosco/storyboard/core'`]
|
|
1123
|
+
const initCalls = [
|
|
1124
|
+
`init({ flows, objects, records, prototypes, folders, canvases, stories })`,
|
|
1125
|
+
`initKnobs({ prototypeKnobs, componentKnobs })`,
|
|
1126
|
+
]
|
|
1093
1127
|
|
|
1094
1128
|
// Build unified config from all sources
|
|
1095
1129
|
const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
|
|
@@ -1183,6 +1217,8 @@ function generateModule(buildResult, root) {
|
|
|
1183
1217
|
`const objects = {\n${entries.object.join(',\n')}\n}`,
|
|
1184
1218
|
`const records = {\n${entries.record.join(',\n')}\n}`,
|
|
1185
1219
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
1220
|
+
`const prototypeKnobs = ${JSON.stringify(discovery.prototypeKnobs || {})}`,
|
|
1221
|
+
`const componentKnobs = ${JSON.stringify(discovery.componentKnobs || {})}`,
|
|
1186
1222
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
1187
1223
|
`const canvases = {\n${entries.canvas.join(',\n')}\n}`,
|
|
1188
1224
|
`const stories = {\n${storyEntries.join(',\n')}\n}`,
|
|
@@ -1195,8 +1231,8 @@ function generateModule(buildResult, root) {
|
|
|
1195
1231
|
'',
|
|
1196
1232
|
initCalls.join('\n'),
|
|
1197
1233
|
'',
|
|
1198
|
-
`export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
|
|
1199
|
-
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
|
|
1234
|
+
`export { flows, scenes, objects, records, prototypes, prototypeKnobs, componentKnobs, folders, canvases, canvasAliases, stories }`,
|
|
1235
|
+
`export const index = { flows, scenes, objects, records, prototypes, prototypeKnobs, componentKnobs, folders, canvases, canvasAliases, stories }`,
|
|
1200
1236
|
`export default index`,
|
|
1201
1237
|
'',
|
|
1202
1238
|
'// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
|
|
@@ -1213,6 +1249,7 @@ function generateModule(buildResult, root) {
|
|
|
1213
1249
|
' : data.metadata',
|
|
1214
1250
|
' }',
|
|
1215
1251
|
' init({ flows, objects, records, prototypes, folders, canvases, stories })',
|
|
1252
|
+
' initKnobs({ prototypeKnobs, componentKnobs })',
|
|
1216
1253
|
' document.dispatchEvent(new CustomEvent("storyboard:canvas-index-changed"))',
|
|
1217
1254
|
' })',
|
|
1218
1255
|
' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
|
|
@@ -1224,6 +1261,7 @@ function generateModule(buildResult, root) {
|
|
|
1224
1261
|
' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
|
|
1225
1262
|
' }',
|
|
1226
1263
|
' init({ flows, objects, records, prototypes, folders, canvases, stories })',
|
|
1264
|
+
' initKnobs({ prototypeKnobs, componentKnobs })',
|
|
1227
1265
|
' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
|
|
1228
1266
|
' })',
|
|
1229
1267
|
'}',
|
|
@@ -76,14 +76,14 @@ describe('storyboardDataPlugin', () => {
|
|
|
76
76
|
const plugin = createPlugin()
|
|
77
77
|
const code = plugin.load(RESOLVED_ID)
|
|
78
78
|
|
|
79
|
-
expect(code).toContain("import { init } from '@dfosco/storyboard/core'")
|
|
79
|
+
expect(code).toContain("import { init, initKnobs } from '@dfosco/storyboard/core'")
|
|
80
80
|
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
|
|
81
81
|
expect(code).toContain('"Test"')
|
|
82
82
|
expect(code).toContain('"Jane"')
|
|
83
83
|
expect(code).toContain('"First"')
|
|
84
84
|
// Backward-compat alias
|
|
85
85
|
expect(code).toContain('const scenes = flows')
|
|
86
|
-
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
|
|
86
|
+
expect(code).toContain('export { flows, scenes, objects, records, prototypes, prototypeKnobs, componentKnobs, folders, canvases, canvasAliases, stories }')
|
|
87
87
|
})
|
|
88
88
|
|
|
89
89
|
it('load returns null for other IDs', () => {
|
|
@@ -1115,7 +1115,7 @@ describe('canvas watcher behavior', () => {
|
|
|
1115
1115
|
|
|
1116
1116
|
expect(code).toContain('const stories = {')
|
|
1117
1117
|
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
|
|
1118
|
-
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
|
|
1118
|
+
expect(code).toContain('export { flows, scenes, objects, records, prototypes, prototypeKnobs, componentKnobs, folders, canvases, canvasAliases, stories }')
|
|
1119
1119
|
})
|
|
1120
1120
|
|
|
1121
1121
|
it('infers /components/ route for stories in src/canvas/', () => {
|
package/storyboard.config.json
CHANGED
package/toolbar.config.json
CHANGED
|
@@ -36,7 +36,20 @@
|
|
|
36
36
|
"handler": "core:flows",
|
|
37
37
|
"excludeRoutes": ["^/$", "^/workspace", "^/viewfinder", "/canvas/", "/components/", "/notebook/"],
|
|
38
38
|
"hideInCommandPalette": ["^/$", "^/workspace", "^/viewfinder", "/canvas/", "/components/", "/notebook/"]
|
|
39
|
-
},
|
|
39
|
+
},
|
|
40
|
+
"knobs": {
|
|
41
|
+
"label": "Knobs",
|
|
42
|
+
"ariaLabel": "Tweak knobs",
|
|
43
|
+
"icon": "iconoir/select-point-3d",
|
|
44
|
+
"render": "knobs-panel",
|
|
45
|
+
"surface": "command-toolbar",
|
|
46
|
+
"handler": "core:knobs",
|
|
47
|
+
"modes": ["prototype"],
|
|
48
|
+
"prod": true,
|
|
49
|
+
"excludeRoutes": ["^/$", "^/workspace", "^/viewfinder", "/canvas/", "/components/", "/notebook/"],
|
|
50
|
+
"hideInCommandPalette": ["^/$", "^/workspace", "^/viewfinder", "/canvas/", "/components/", "/notebook/"],
|
|
51
|
+
"shortcut": { "key": "K", "label": "cmd + shift + k" }
|
|
52
|
+
},
|
|
40
53
|
"theme": {
|
|
41
54
|
"label": "Theme",
|
|
42
55
|
"ariaLabel": "Switch theme",
|
|
@@ -152,7 +165,7 @@
|
|
|
152
165
|
"ariaLabel": "Zoom to objects",
|
|
153
166
|
"label": "Zoom to objects",
|
|
154
167
|
"icon": "iconoir/square-3d-three-points",
|
|
155
|
-
"meta": { "strokeWeight":
|
|
168
|
+
"meta": { "strokeWeight": 1.5, "scale": 0.5 },
|
|
156
169
|
"render": "canvas-zoom-to-fit",
|
|
157
170
|
"surface": "canvas-toolbar",
|
|
158
171
|
"handler": "core:canvas-toolbar",
|