@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.
Files changed (166) hide show
  1. package/commandpalette.config.json +6 -0
  2. package/dist/storyboard-ui.css +1 -1
  3. package/dist/storyboard-ui.js +16450 -12737
  4. package/dist/storyboard-ui.js.map +1 -1
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +66 -2
  7. package/scaffold/src/_prototype.jsx +9 -1
  8. package/src/core/artifact/schemas/component.schema.json +55 -0
  9. package/src/core/artifact/schemas/prototype.schema.json +55 -0
  10. package/src/core/canvas/materializer.js +120 -105
  11. package/src/core/canvas/materializer.test.js +172 -1
  12. package/src/core/canvas/server.js +156 -11
  13. package/src/core/cli/canvasConnector.js +51 -2
  14. package/src/core/cli/configureRepository.js +164 -0
  15. package/src/core/cli/dev.js +14 -23
  16. package/src/core/cli/file.js +333 -0
  17. package/src/core/cli/gh.js +101 -0
  18. package/src/core/cli/index.js +4 -0
  19. package/src/core/cli/repoConfig.js +109 -0
  20. package/src/core/cli/repoConfig.test.js +119 -0
  21. package/src/core/cli/setup.js +15 -0
  22. package/src/core/cli/workleaf.js +3 -3
  23. package/src/core/comments/ui/mount.js +27 -0
  24. package/src/core/file/__tests__/server.test.js +604 -0
  25. package/src/core/file/__tests__/tree.test.js +190 -0
  26. package/src/core/file/server.js +519 -0
  27. package/src/core/file/tree.js +151 -0
  28. package/src/core/file-watcher/config.json +28 -0
  29. package/src/core/file-watcher/watcher.js +891 -0
  30. package/src/core/{rename-watcher → file-watcher}/watcher.test.js +142 -1
  31. package/src/core/index.js +4 -1
  32. package/src/core/knobs/coerce.js +93 -0
  33. package/src/core/knobs/coerce.test.js +51 -0
  34. package/src/core/knobs/discovery.js +109 -0
  35. package/src/core/knobs/discovery.test.js +69 -0
  36. package/src/core/knobs/index.js +18 -0
  37. package/src/core/knobs/keys.js +26 -0
  38. package/src/core/knobs/keys.test.js +28 -0
  39. package/src/core/knobs/writer.js +117 -0
  40. package/src/core/knobs/writer.test.js +113 -0
  41. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +10 -2
  42. package/src/core/mountStoryboardCore.js +1 -0
  43. package/src/core/rename-watcher/watcher.js +3 -824
  44. package/src/core/session/session.js +31 -3
  45. package/src/core/session/session.test.js +31 -75
  46. package/src/core/stores/canvasConfig.js +31 -1
  47. package/src/core/stores/canvasConfig.test.js +50 -1
  48. package/src/core/stores/canvasInteractionStore.js +29 -9
  49. package/src/core/stores/canvasInteractionStore.test.js +37 -11
  50. package/src/core/stores/configSchema.js +58 -0
  51. package/src/core/stores/configSchema.test.js +7 -0
  52. package/src/core/stores/themeBootstrap.js +18 -1
  53. package/src/core/stores/themeBootstrap.test.js +17 -6
  54. package/src/core/stores/uiConfig.js +2 -1
  55. package/src/core/styles/tailwind.css +2 -0
  56. package/src/core/tools/handlers/knobs.js +14 -0
  57. package/src/core/tools/registry.js +1 -0
  58. package/src/core/ui/CanvasCreateMenu.jsx +9 -1
  59. package/src/core/ui/CanvasSnap.jsx +14 -7
  60. package/src/core/ui/CanvasZoomToFit.jsx +11 -8
  61. package/src/core/ui/CoreUIBar.jsx +70 -3
  62. package/src/core/ui/KnobsPanel.jsx +320 -0
  63. package/src/core/ui/KnobsPanel.module.css +118 -0
  64. package/src/core/ui/KnobsPanel.test.jsx +135 -0
  65. package/src/core/ui/knobsPanelState.js +25 -0
  66. package/src/core/vite/server-plugin.js +4 -0
  67. package/src/core/workleaf/create.js +62 -12
  68. package/src/core/workleaf/nameGenerator.js +133 -0
  69. package/src/core/workleaf/nameGenerator.test.js +99 -0
  70. package/src/internals/BaseUiForm/BaseUiForm.jsx +85 -3
  71. package/src/internals/BaseUiForm/BaseUiForm.module.css +234 -2
  72. package/src/internals/BranchBar/BranchBar.jsx +3 -1
  73. package/src/internals/CommandPalette/CommandPalette.jsx +55 -25
  74. package/src/internals/Knobs/KnobsForm.jsx +633 -0
  75. package/src/internals/Knobs/KnobsForm.module.css +162 -0
  76. package/src/internals/Knobs/KnobsForm.test.jsx +107 -0
  77. package/src/internals/Knobs/index.js +1 -0
  78. package/src/internals/canvas/CanvasPage.bridge.test.jsx +2 -0
  79. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +192 -0
  80. package/src/internals/canvas/CanvasPage.jsx +629 -197
  81. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
  82. package/src/internals/canvas/CanvasToolbar.jsx +6 -0
  83. package/src/internals/canvas/ConnectorLayer.jsx +54 -12
  84. package/src/internals/canvas/ConnectorLayer.module.css +67 -0
  85. package/src/internals/canvas/PageSelector.jsx +187 -202
  86. package/src/internals/canvas/PageSelector.module.css +12 -6
  87. package/src/internals/canvas/canvasApi.js +21 -2
  88. package/src/internals/canvas/widgets/CodePenEmbed.jsx +9 -2
  89. package/src/internals/canvas/widgets/FigmaEmbed.jsx +8 -1
  90. package/src/internals/canvas/widgets/FileWidget/CodeEditor.jsx +304 -0
  91. package/src/internals/canvas/widgets/FileWidget/CodeEditor.module.css +57 -0
  92. package/src/internals/canvas/widgets/FileWidget/DeleteConfirmController.jsx +62 -0
  93. package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.jsx +46 -0
  94. package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.module.css +48 -0
  95. package/src/internals/canvas/widgets/FileWidget/FilePicker.jsx +309 -0
  96. package/src/internals/canvas/widgets/FileWidget/FilePicker.module.css +170 -0
  97. package/src/internals/canvas/widgets/FileWidget/FilePickerController.jsx +75 -0
  98. package/src/internals/canvas/widgets/FileWidget/FileSecondaryPane.jsx +167 -0
  99. package/src/internals/canvas/widgets/FileWidget/FileWidget.jsx +658 -0
  100. package/src/internals/canvas/widgets/FileWidget/FileWidget.module.css +359 -0
  101. package/src/internals/canvas/widgets/FileWidget/MdxRenderer.jsx +153 -0
  102. package/src/internals/canvas/widgets/FileWidget/MdxRenderer.module.css +81 -0
  103. package/src/internals/canvas/widgets/FileWidget/__tests__/CodeEditor.test.jsx +235 -0
  104. package/src/internals/canvas/widgets/FileWidget/__tests__/DeleteConfirmDialog.test.jsx +51 -0
  105. package/src/internals/canvas/widgets/FileWidget/__tests__/FilePicker.test.jsx +329 -0
  106. package/src/internals/canvas/widgets/FileWidget/__tests__/FileWidget.test.jsx +525 -0
  107. package/src/internals/canvas/widgets/FileWidget/__tests__/MdxRenderer.test.jsx +81 -0
  108. package/src/internals/canvas/widgets/FileWidget/__tests__/fileContentStore.test.js +404 -0
  109. package/src/internals/canvas/widgets/FileWidget/__tests__/fileFlavor.test.js +287 -0
  110. package/src/internals/canvas/widgets/FileWidget/fileContentStore.js +328 -0
  111. package/src/internals/canvas/widgets/FileWidget/fileFlavor.js +250 -0
  112. package/src/internals/canvas/widgets/KnobsWidget.jsx +229 -0
  113. package/src/internals/canvas/widgets/KnobsWidget.module.css +144 -0
  114. package/src/internals/canvas/widgets/KnobsWidget.noreload.test.jsx +215 -0
  115. package/src/internals/canvas/widgets/KnobsWidget.test.jsx +237 -0
  116. package/src/internals/canvas/widgets/MarkdownBlock.jsx +32 -152
  117. package/src/internals/canvas/widgets/MarkdownBlock.module.css +17 -328
  118. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +20 -2
  119. package/src/internals/canvas/widgets/StickyNote.jsx +43 -34
  120. package/src/internals/canvas/widgets/StickyNote.module.css +123 -4
  121. package/src/internals/canvas/widgets/StickyNote.test.jsx +95 -0
  122. package/src/internals/canvas/widgets/StorySetWidget.jsx +33 -6
  123. package/src/internals/canvas/widgets/StoryWidget.jsx +18 -0
  124. package/src/internals/canvas/widgets/TerminalWidget.jsx +46 -0
  125. package/src/internals/canvas/widgets/expandUtils.js +63 -4
  126. package/src/internals/canvas/widgets/iframeRefContract.test.jsx +102 -0
  127. package/src/internals/canvas/widgets/iframeSrcCompare.js +38 -0
  128. package/src/internals/canvas/widgets/iframeSrcCompare.test.js +65 -0
  129. package/src/internals/canvas/widgets/index.js +4 -2
  130. package/src/internals/canvas/widgets/knobsTargetResolver.js +215 -0
  131. package/src/internals/canvas/widgets/knobsTargetResolver.test.js +142 -0
  132. package/src/internals/canvas/widgets/markdown/MarkdownEditor.jsx +175 -0
  133. package/src/internals/canvas/widgets/markdown/MarkdownEditor.module.css +11 -0
  134. package/src/internals/canvas/widgets/markdown/MarkdownView.test.jsx +32 -0
  135. package/src/internals/canvas/widgets/markdown/markdownClass.js +16 -0
  136. package/src/internals/canvas/widgets/markdown/markdownContent.module.css +230 -0
  137. package/src/internals/canvas/widgets/markdown/markdownRender.js +53 -0
  138. package/src/internals/canvas/widgets/storyPath.js +31 -0
  139. package/src/internals/canvas/widgets/storyPath.test.js +59 -0
  140. package/src/internals/canvas/widgets/widgetConfig.js +92 -2
  141. package/src/internals/canvas/widgets/widgetConfig.knobs.test.js +42 -0
  142. package/src/internals/canvas/widgets/widgetConfig.merged.test.js +27 -0
  143. package/src/internals/canvas/widgets/widgetConfig.test.js +33 -2
  144. package/src/internals/canvas/widgets/widgetIcons.jsx +6 -1
  145. package/src/internals/hooks/useKnob.js +97 -0
  146. package/src/internals/hooks/useKnob.test.js +133 -0
  147. package/src/internals/index.js +2 -0
  148. package/src/internals/story/ComponentSetPage.jsx +21 -9
  149. package/src/internals/vite/data-plugin.js +45 -7
  150. package/src/internals/vite/data-plugin.test.js +3 -3
  151. package/storyboard.config.json +6 -0
  152. package/toolbar.config.json +15 -2
  153. package/widgets.config.json +235 -133
  154. package/src/core/ui/CanvasSnap.css +0 -27
  155. package/src/core/ui/CanvasZoomToFit.css +0 -18
  156. package/src/internals/canvas/widgets/TilesWidget.jsx +0 -300
  157. package/src/internals/canvas/widgets/TilesWidget.module.css +0 -133
  158. package/src/internals/canvas/widgets/tilePool.js +0 -23
  159. package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
  160. package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
  161. package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
  162. package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
  163. package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
  164. package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
  165. package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
  166. 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
+ })
@@ -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 = searchParams.get('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 params = new URLSearchParams(location.search)
161
- if (exportName === params.get('selected')) {
162
- params.delete('selected')
163
- } else {
164
- params.set('selected', exportName)
165
- }
166
- navigate(`${location.pathname}?${params}`, { replace: true })
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: exportName === selected ? null : 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 = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
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/', () => {
@@ -33,6 +33,12 @@
33
33
  "resizable": false,
34
34
  "defaultWidth": 800,
35
35
  "defaultHeight": 450
36
+ },
37
+ "fileEditor": {
38
+ "theme": {
39
+ "light": null,
40
+ "dark": "oneDark"
41
+ }
36
42
  }
37
43
  },
38
44
  "customerMode": {
@@ -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": 2, "scale": 1.2 },
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",