@dfosco/storyboard-core 3.3.1 → 3.4.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 (52) hide show
  1. package/dist/storyboard-ui.css +9 -1
  2. package/dist/storyboard-ui.js +14701 -11431
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -1
  6. package/scaffold/toolbar.config.json +2 -2
  7. package/src/CanvasCreateMenu.svelte +1 -1
  8. package/src/CanvasZoomControl.svelte +105 -0
  9. package/src/CommandMenu.svelte +87 -25
  10. package/src/CoreUIBar.svelte +352 -346
  11. package/src/CreateMenuButton.svelte +6 -2
  12. package/src/InspectorPanel.svelte +87 -37
  13. package/src/SidePanel.svelte +1 -1
  14. package/src/ThemeMenuButton.svelte +35 -3
  15. package/src/commandActions.js +14 -0
  16. package/src/core-ui-colors.css +30 -2
  17. package/src/devtools.js +7 -1
  18. package/src/index.js +10 -1
  19. package/src/inspector/fiberWalker.js +49 -6
  20. package/src/inspector/highlighter.js +145 -29
  21. package/src/lib/components/ui/button/button.svelte +1 -1
  22. package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
  23. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +1 -1
  24. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
  25. package/src/lib/components/ui/trigger-button/trigger-button.svelte +31 -3
  26. package/src/modes.css +8 -0
  27. package/src/mountStoryboardCore.js +15 -1
  28. package/src/stores/themeStore.ts +66 -0
  29. package/src/svelte-plugin-ui/components/Viewfinder.svelte +16 -11
  30. package/src/toolRegistry.js +226 -0
  31. package/src/toolStateStore.js +180 -0
  32. package/src/toolStateStore.test.js +204 -0
  33. package/src/toolbarConfigStore.js +135 -0
  34. package/src/tools/handlers/canvasAddWidget.js +11 -0
  35. package/src/tools/handlers/canvasZoom.js +34 -0
  36. package/src/tools/handlers/comments.js +16 -0
  37. package/src/tools/handlers/create.js +39 -0
  38. package/src/tools/handlers/devtools.js +80 -0
  39. package/src/tools/handlers/docs.js +11 -0
  40. package/src/tools/handlers/featureFlags.js +21 -0
  41. package/src/tools/handlers/flows.js +59 -0
  42. package/src/tools/handlers/inspector.js +19 -0
  43. package/src/tools/handlers/theme.js +9 -0
  44. package/src/tools/registry.js +21 -0
  45. package/src/tools/surfaces/canvasToolbar.js +10 -0
  46. package/src/tools/surfaces/commandList.js +10 -0
  47. package/src/tools/surfaces/mainToolbar.js +11 -0
  48. package/src/tools/surfaces/registry.js +19 -0
  49. package/src/vite/server-plugin.js +54 -5
  50. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  51. package/src/workshop/features/createPrototype/server.js +10 -15
  52. package/toolbar.config.json +107 -48
@@ -10,66 +10,184 @@
10
10
  -->
11
11
 
12
12
  <script lang="ts">
13
- import { onMount, onDestroy } from 'svelte'
13
+ import { onMount, onDestroy, untrack } from 'svelte'
14
14
  import './core-ui-colors.css'
15
15
  import CommandMenu from './CommandMenu.svelte'
16
16
  import { TriggerButton } from './lib/components/ui/trigger-button/index.js'
17
17
  import * as Tooltip from './lib/components/ui/tooltip/index.js'
18
18
  import Icon from './svelte-plugin-ui/components/Icon.svelte'
19
19
  import { modeState } from './svelte-plugin-ui/stores/modeStore.js'
20
- import { sidePanelState, togglePanel, openPanel } from './stores/sidePanelStore.js'
21
- import { initCommandActions, registerCommandAction, getActionChildren, isExcludedByRoute, setRoutingBasePath } from './commandActions.js'
20
+ import { sidePanelState, togglePanel } from './stores/sidePanelStore.js'
21
+ import { initCommandActions, registerCommandAction, getActionChildren, hasChildrenProvider, isExcludedByRoute, setRoutingBasePath } from './commandActions.js'
22
22
  import { isMenuHidden } from './uiConfig.js'
23
+ import { subscribeToToolbarConfig, getToolbarConfig } from './toolbarConfigStore.js'
24
+ import { initToolbarToolStates, getToolbarToolState, isToolbarToolLocalOnly, subscribeToToolbarToolStates } from './toolStateStore.js'
23
25
  import defaultToolbarConfig from '../toolbar.config.json'
24
26
 
25
- interface Props { basePath?: string; toolbarConfig?: any }
26
- let { basePath = '/', toolbarConfig }: Props = $props()
27
+ interface Props { basePath?: string; toolbarConfig?: any; customHandlers?: Record<string, () => Promise<any>> }
28
+ let { basePath = '/', toolbarConfig, customHandlers = {} }: Props = $props()
27
29
 
28
- // Use provided config (merged by mountStoryboardCore) or fall back to defaults
29
- const config = $derived(toolbarConfig || defaultToolbarConfig)
30
+ // Reactive toolbar config subscribes to the config store for prototype overrides.
31
+ // Falls back to the prop (for backward compat) or the bundled defaults.
32
+ let storeConfig = $state(getToolbarConfig())
33
+ let unsubConfig: (() => void) | null = null
34
+
35
+ $effect(() => {
36
+ unsubConfig = subscribeToToolbarConfig((cfg: any) => { storeConfig = cfg })
37
+ return () => { if (unsubConfig) unsubConfig() }
38
+ })
39
+
40
+ $effect(() => {
41
+ const unsub = subscribeToToolbarToolStates(() => { toolStateVersion++ })
42
+ return unsub
43
+ })
44
+
45
+ // Use store config if available, otherwise fall back to prop or defaults
46
+ const config = $derived(
47
+ (storeConfig && Object.keys(storeConfig).length > 0)
48
+ ? storeConfig
49
+ : (toolbarConfig || defaultToolbarConfig)
50
+ )
51
+
52
+ // Re-seed tool states whenever config changes (e.g. prototype override on navigation)
53
+ $effect(() => {
54
+ const tools = config.tools || {}
55
+ // untrack so the synchronous _notify() → toolStateVersion++ inside
56
+ // initToolbarToolStates doesn't get tracked as a dependency of this effect
57
+ untrack(() => initToolbarToolStates(tools, { isLocalDev }))
58
+ })
30
59
 
31
60
  let visible = $state(true)
32
61
  // Hide the entire toolbar when loaded inside a prototype embed iframe
33
62
  const isEmbed = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('_sb_embed')
34
63
  let commandMenuOpen = $state(false)
35
- let ActionMenuButton: any = $state(null)
64
+ let toolComponents: Record<string, any> = $state({})
65
+ let toolData: Record<string, any> = $state({})
36
66
  let navVersion = $state(0)
37
67
  let origPushState: typeof history.pushState
38
68
  let origReplaceState: typeof history.replaceState
39
69
  let bumpNav: () => void
40
- let CreateMenuButton: any = $state(null)
41
- let createMenuFeatures: any[] = $state([])
42
- let CommentsMenuButton: any = $state(null)
43
- let ThemeMenuButton: any = $state(null)
44
- let commentsEnabled = $state(false)
45
70
  let SidePanel: any = $state(null)
46
71
  let toolbarEl: HTMLElement | null = $state(null)
47
- let CanvasCreateMenu: any = $state(null)
48
72
  let canvasActive = $state(false)
49
73
  let activeCanvasName = $state('')
50
74
  let canvasZoom = $state(100)
51
- const canvasToolbarConfig = $derived((config as any).canvasToolbar || {})
52
-
53
- const ZOOM_STEP = 10
54
- const ZOOM_MIN = 25
55
- const ZOOM_MAX = 200
75
+ let toolStateVersion = $state(0)
56
76
 
57
77
  // Roving tabindex: only one button in the toolbar is tabbable at a time
58
78
  let activeToolbarIndex = $state(-1)
59
79
 
60
- const commandMenuConfig = $derived(isMenuHidden('command') ? null : config.menus?.command)
61
- const shortcutsConfig = $derived((config as any).shortcuts || {})
80
+ const isLocalDev = typeof window !== 'undefined' && (window as any).__SB_LOCAL_DEV__ === true
81
+
82
+ /**
83
+ * Resolve a handler reference to a module loader function.
84
+ * Format: "core:name" → core registry, "custom:name" → client handlers
85
+ */
86
+ function resolveHandlerModule(
87
+ ref: string,
88
+ coreModules: Record<string, Function>,
89
+ custom: Record<string, () => Promise<any>>
90
+ ): Function | null {
91
+ const colonIdx = ref.indexOf(':')
92
+ if (colonIdx === -1) return coreModules[ref] || null
93
+ const prefix = ref.slice(0, colonIdx)
94
+ const name = ref.slice(colonIdx + 1)
95
+ if (prefix === 'core') return coreModules[name] || null
96
+ if (prefix === 'custom') return custom[name] || null
97
+ return null
98
+ }
99
+
100
+ // Resolve tools → menus compatibility layer.
101
+ // New config uses `tools` (flat map with toolbar target); legacy uses `menus`.
102
+ // When `tools` exists, derive the menus-compatible structures from it.
103
+ function resolveMenus(cfg: any): Record<string, any> {
104
+ if (cfg.tools) {
105
+ const result: Record<string, any> = {}
106
+ for (const [key, tool] of Object.entries(cfg.tools as Record<string, any>)) {
107
+ if (tool.surface === 'command-list' || tool.surface === 'canvas-toolbar') continue
108
+ // Map new render/toolbar fields to legacy menu fields for rendering compat
109
+ const menu: any = { ...tool }
110
+ if (tool.render === 'menu' && tool.handler) {
111
+ menu.action = tool.handler
112
+ }
113
+ result[key] = { ...menu, _toolId: key }
114
+ }
115
+ return result
116
+ }
117
+ return cfg.menus || {}
118
+ }
119
+
120
+ function resolveCommandConfig(cfg: any): any {
121
+ if (cfg.command) {
122
+ // Build command menu config from new schema
123
+ const actions: any[] = []
124
+ actions.push({ type: 'header', label: 'Command Menu' })
125
+
126
+ // Add command-list tools as actions
127
+ if (cfg.tools) {
128
+ for (const [toolKey, tool] of Object.entries(cfg.tools as Record<string, any>)) {
129
+ if (tool.surface !== 'command-list') continue
130
+ if (tool.render === 'separator') {
131
+ actions.push({ type: 'separator' })
132
+ continue
133
+ }
134
+ actions.push({
135
+ id: tool.handler || `core/${tool.label?.toLowerCase().replace(/\s+/g, '-')}`,
136
+ label: tool.label || tool.ariaLabel,
137
+ type: tool.render || 'default',
138
+ url: tool.url || null,
139
+ modes: tool.modes || ['*'],
140
+ toolKey,
141
+ localOnly: tool.localOnly || false,
142
+ })
143
+ }
144
+ }
145
+
146
+ return {
147
+ ariaLabel: 'Command Menu',
148
+ trigger: 'command',
149
+ icon: cfg.command.icon,
150
+ meta: cfg.command.meta,
151
+ default: true,
152
+ modes: ['*'],
153
+ actions,
154
+ }
155
+ }
156
+ return cfg.menus?.command || null
157
+ }
158
+
159
+ const commandMenuConfig = $derived(
160
+ isMenuHidden('command') ? null : resolveCommandConfig(config)
161
+ )
162
+ const shortcutsConfig = $derived({
163
+ ...((config as any).shortcuts || {}),
164
+ ...(config.command?.shortcut ? { openCommandMenu: config.command.shortcut } : {}),
165
+ })
62
166
 
63
167
  // Build ordered menu list from JSON key order (excluding command, which is always rightmost)
64
- const allMenus = $derived((config.menus || {}) as Record<string, any>)
168
+ const allMenus = $derived(resolveMenus(config))
65
169
  const orderedMenus = $derived(Object.entries(allMenus)
66
170
  .filter(([key]) => key !== 'command')
67
171
  .filter(([key]) => !isMenuHidden(key))
172
+ .filter(([key]) => {
173
+ void toolStateVersion
174
+ return getToolbarToolState(key) !== 'disabled'
175
+ })
68
176
  .map(([key, menu]) => ({ key, ...menu })))
69
177
 
70
178
  // Discover menus with sidepanel property
71
179
  const sidepanelMenus = $derived(orderedMenus.filter(menu => menu.sidepanel))
72
180
 
181
+ // Canvas toolbar tools — only visible when a canvas page is active
182
+ const canvasMenus = $derived(
183
+ config.tools
184
+ ? Object.entries(config.tools as Record<string, any>)
185
+ .filter(([, tool]) => tool.surface === 'canvas-toolbar')
186
+ .filter(([, tool]) => !tool.localOnly || isLocalDev)
187
+ .map(([key, tool]) => ({ key, ...tool }))
188
+ : []
189
+ )
190
+
73
191
  function menuVisibleInMode(menu: any, mode: string): boolean {
74
192
  if (!menu?.modes) return false
75
193
  if (isExcludedByRoute(menu)) return false
@@ -81,21 +199,51 @@
81
199
  orderedMenus
82
200
  .filter(menu => {
83
201
  void navVersion
202
+ void toolStateVersion
203
+ const toolState = getToolbarToolState(menu.key)
204
+ if (toolState === 'hidden') return false
205
+ if (menu.render === 'separator') return true
84
206
  if (!menuVisibleInMode(menu, $modeState.mode)) return false
85
- if (menu.action) return ActionMenuButton && getActionChildren(menu.action).length > 0
86
- if (menu.key === 'create') return CreateMenuButton && createMenuFeatures.length > 0
87
- if (menu.key === 'comments') return CommentsMenuButton && commentsEnabled
88
- if (menu.key === 'theme') return !!ThemeMenuButton
207
+ if (menu.render === 'sidepanel') return true
208
+ // For tools with components, check if loaded
209
+ if (!toolComponents[menu.key]) return false
210
+ // For action-menu tools (those with a getChildren handler), hide when empty.
211
+ // Custom-component menus (e.g. ThemeMenuButton) render their own content.
212
+ const actionId = menu.handler || menu.action
213
+ if (actionId && menu.render === 'menu' && hasChildrenProvider(actionId)) {
214
+ return getActionChildren(actionId).length > 0
215
+ }
89
216
  return true
90
217
  })
91
218
  .reverse()
92
219
  )
93
220
 
221
+ // Clean separators: remove leading, trailing, and consecutive
222
+ const cleanedMenus = $derived.by(() => {
223
+ const result: typeof visibleMenus = []
224
+ for (const item of visibleMenus) {
225
+ if (item.render === 'separator') {
226
+ // Skip if first item or previous was also a separator
227
+ if (result.length === 0 || result[result.length - 1].render === 'separator') continue
228
+ result.push(item)
229
+ } else {
230
+ result.push(item)
231
+ }
232
+ }
233
+ // Remove trailing separator
234
+ while (result.length > 0 && result[result.length - 1].render === 'separator') result.pop()
235
+ return result
236
+ })
237
+
94
238
  // Total toolbar item count (visible menus + command menu if present)
95
- const toolbarItemCount = $derived(visibleMenus.length + (commandMenuConfig ? 1 : 0))
239
+ const toolbarItemCount = $derived(
240
+ cleanedMenus.filter(m => m.render !== 'separator').length + (commandMenuConfig ? 1 : 0)
241
+ )
96
242
 
97
243
  // Command menu is always the last item (rightmost)
98
- const commandMenuIndex = $derived(commandMenuConfig ? visibleMenus.length : -1)
244
+ const commandMenuIndex = $derived(
245
+ commandMenuConfig ? cleanedMenus.filter(m => m.render !== 'separator').length : -1
246
+ )
99
247
 
100
248
  function getTabindex(index: number): number {
101
249
  if (activeToolbarIndex < 0) {
@@ -171,20 +319,19 @@
171
319
  e.preventDefault()
172
320
  commandMenuOpen = !commandMenuOpen
173
321
  }
174
- // Cmd+D toggle documentation panel
175
- if (e.key === 'd' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
176
- const docsMenu = visibleMenus.find(m => m.sidepanel === 'docs')
177
- if (docsMenu) {
178
- e.preventDefault()
179
- togglePanel('docs')
180
- }
181
- }
182
- // Cmd+I — toggle inspector panel
183
- if (e.key === 'i' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
184
- const inspectorMenu = visibleMenus.find(m => m.sidepanel === 'inspector')
185
- if (inspectorMenu) {
186
- e.preventDefault()
187
- togglePanel('inspector')
322
+ // Config-driven tool shortcuts (e.g. Cmd+D for docs, Cmd+I for inspector)
323
+ for (const menu of cleanedMenus) {
324
+ const shortcut = menu.shortcut
325
+ if (!shortcut?.key) continue
326
+ if (e.key === shortcut.key && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
327
+ const toolState = getToolbarToolState(menu.key)
328
+ // Inactive and disabled tools don't respond to shortcuts
329
+ if (toolState === 'inactive' || toolState === 'disabled') break
330
+ if (menu.sidepanel) {
331
+ e.preventDefault()
332
+ togglePanel(menu.sidepanel)
333
+ }
334
+ break
188
335
  }
189
336
  }
190
337
  }
@@ -193,210 +340,102 @@
193
340
  window.addEventListener('keydown', handleKeydown)
194
341
  setRoutingBasePath(basePath)
195
342
 
196
- // Re-evaluate action menus on SPA navigation
197
- bumpNav = () => { navVersion++ }
343
+ // Re-evaluate action menus and prototype toolbar config on SPA navigation
344
+ const { getPrototypeMetadata } = await import('./loader.js')
345
+ const { setPrototypeToolbarConfig, clearPrototypeToolbarConfig } = await import('./toolbarConfigStore.js')
346
+
347
+ function syncPrototypeToolbar() {
348
+ let pathname = window.location.pathname
349
+ const base = basePath.replace(/\/+$/, '')
350
+ if (base && pathname.startsWith(base)) pathname = pathname.slice(base.length)
351
+ const firstSegment = pathname.replace(/^\//, '').split('/')[0] || null
352
+ if (firstSegment) {
353
+ const meta = getPrototypeMetadata(firstSegment)
354
+ if (meta?.toolbarConfig) {
355
+ setPrototypeToolbarConfig(meta.toolbarConfig)
356
+ } else {
357
+ clearPrototypeToolbarConfig()
358
+ }
359
+ } else {
360
+ clearPrototypeToolbarConfig()
361
+ }
362
+ }
363
+
364
+ bumpNav = () => { navVersion++; syncPrototypeToolbar() }
198
365
  window.addEventListener('popstate', bumpNav)
199
366
  origPushState = history.pushState.bind(history)
200
367
  history.pushState = (...args: any[]) => { origPushState(...args); bumpNav() }
201
368
  origReplaceState = history.replaceState.bind(history)
202
369
  history.replaceState = (...args: any[]) => { origReplaceState(...args); bumpNav() }
203
370
 
371
+ // Apply prototype toolbar config for the initial route
372
+ syncPrototypeToolbar()
373
+
204
374
  // Seed the command action registry from config
205
375
  if (commandMenuConfig) {
206
376
  initCommandActions(commandMenuConfig)
207
377
  }
208
378
 
209
- // Register core action handlers
210
- registerCommandAction('core/viewfinder', () => {
211
- window.location.href = basePath + 'viewfinder'
212
- })
213
-
214
379
  // Register sidepanel toggle actions
215
380
  for (const menu of sidepanelMenus) {
216
- registerCommandAction(`core/${menu.key}`, () => {
381
+ registerCommandAction(`core:${menu.key}`, () => {
217
382
  togglePanel(menu.sidepanel)
218
383
  })
219
384
  }
220
385
 
221
- // Auto-open inspector panel if ?inspect= param is in the URL
222
- try {
223
- const inspectParam = new URL(window.location.href).searchParams.get('inspect')
224
- if (inspectParam) {
225
- openPanel('inspector')
226
- }
227
- } catch {}
386
+ // Load all tool modules from the registry
387
+ const { coreHandlers } = await import('./tools/registry.js')
388
+ const toolConfigs = config.tools || {}
389
+ const ctx = { basePath, showFlowInfoDialog }
228
390
 
229
- // Register devtools submenu (show flow info, reset params, hide mode, logout)
230
- {
231
- let loader: any = null
232
- let hm: any = null
233
- let commentsAuth: any = null
234
- try { loader = await import('./loader.js') } catch {}
235
- try { hm = await import('./hideMode.js') } catch {}
236
- try { commentsAuth = await import('./comments/auth.js') } catch {}
237
-
238
- registerCommandAction('core/devtools', {
239
- getChildren: () => {
240
- const children: any[] = []
241
- if (loader) {
242
- children.push({
243
- id: 'core/show-flow-info',
244
- label: 'Show flow info',
245
- type: 'default',
246
- execute: () => {
247
- const p = new URLSearchParams(window.location.search)
248
- const name = p.get('flow') || p.get('scene') || 'default'
249
- try {
250
- const data = loader.loadFlow(name)
251
- showFlowInfoDialog(name, JSON.stringify(data, null, 2), null)
252
- } catch (e: any) {
253
- showFlowInfoDialog(name, '', e.message)
254
- }
255
- },
256
- })
257
- }
258
- children.push({
259
- id: 'core/reset-params',
260
- label: 'Reset all params',
261
- type: 'default',
262
- execute: () => { window.location.hash = '' },
263
- })
264
- if (hm) {
265
- children.push({
266
- id: 'core/hide-mode',
267
- label: 'Hide mode',
268
- type: 'toggle',
269
- active: hm.isHideMode(),
270
- execute: () => {
271
- if (hm.isHideMode()) hm.deactivateHideMode()
272
- else hm.activateHideMode()
273
- },
274
- })
275
- }
276
- if (commentsAuth?.isAuthenticated()) {
277
- children.push({
278
- id: 'core/logout',
279
- label: 'Logout (remove token)',
280
- type: 'default',
281
- execute: () => {
282
- commentsAuth.clearToken()
283
- console.log('[storyboard] Token removed')
284
- },
285
- })
286
- }
287
- return children
288
- },
289
- })
290
- }
391
+ for (const [toolId, toolConfig] of Object.entries(toolConfigs as Record<string, any>)) {
392
+ // Skip non-tool entries (separators have no handler)
393
+ if (toolConfig.render === 'separator') continue
291
394
 
292
- try {
293
- const ff = await import('./featureFlags.js')
294
- registerCommandAction('core/feature-flags', {
295
- getChildren: () =>
296
- ff.getFlagKeys().map((key: string) => ({
297
- id: `flags/${key}`,
298
- label: key,
299
- type: 'toggle' as const,
300
- active: ff.getFlag(key),
301
- execute: () => ff.toggleFlag(key),
302
- })),
303
- })
304
- } catch {}
395
+ // Skip disabled tools — don't load their modules at all
396
+ if (getToolbarToolState(toolId) === 'disabled') continue
305
397
 
306
- // Register flow switcher action (dynamic — reads current prototype from URL)
307
- try {
308
- const loader = await import('./loader.js')
309
- const vf = await import('./viewfinder.js')
310
-
311
- registerCommandAction('core/flows', {
312
- getChildren: () => {
313
- let path = window.location.pathname
314
- const base = basePath.replace(/\/+$/, '')
315
- if (base && path.startsWith(base)) path = path.slice(base.length)
316
- path = path.replace(/\/+$/, '') || '/'
317
- const segments = path.split('/').filter(Boolean)
318
- const proto = segments[0] || null
319
- if (!proto) return []
320
-
321
- // Detect active flow
322
- const params = new URLSearchParams(window.location.search)
323
- const explicit = params.get('flow') || params.get('scene')
324
- let active: string
325
- if (explicit) {
326
- active = loader.resolveFlowName(proto, explicit)
327
- } else {
328
- const pageFlow = path === '/' ? 'index' : (path.split('/').pop() || 'index')
329
- const scoped = loader.resolveFlowName(proto, pageFlow)
330
- if (loader.flowExists(scoped)) active = scoped
331
- else {
332
- const protoFlow = loader.resolveFlowName(proto, proto)
333
- active = loader.flowExists(protoFlow) ? protoFlow : 'default'
334
- }
335
- }
336
-
337
- return loader.getFlowsForPrototype(proto).map((f: any) => {
338
- const meta = vf.getFlowMeta(f.key)
339
- return {
340
- id: f.key,
341
- label: meta?.title || f.name,
342
- type: 'radio' as const,
343
- active: f.key === active,
344
- execute: () => { window.location.href = vf.resolveFlowRoute(f.key) },
345
- }
346
- })
347
- },
348
- })
349
- } catch {}
398
+ // Resolve handler module via core:/custom: prefix
399
+ const handlerRef = toolConfig.handler || `core:${toolId}`
400
+ const loadModule = resolveHandlerModule(handlerRef, coreHandlers, customHandlers)
401
+ if (!loadModule) continue
350
402
 
351
- // Load action menu button (used for any menu with an "action" reference)
352
- try {
353
- const mod = await import('./ActionMenuButton.svelte')
354
- ActionMenuButton = mod.default
355
- } catch {}
403
+ try {
404
+ const mod = await loadModule()
405
+ const toolCtx = { ...ctx, config: toolConfig }
356
406
 
357
- // Load theme menu button
358
- try {
359
- const mod = await import('./ThemeMenuButton.svelte')
360
- ThemeMenuButton = mod.default
361
- } catch {}
407
+ // Run guard skip if guard returns false
408
+ if (mod.guard) {
409
+ const ok = await mod.guard(toolCtx)
410
+ if (!ok) continue
411
+ }
362
412
 
363
- // Load comments menu button
364
- try {
365
- const { isCommentsEnabled } = await import('./comments/config.js')
366
- if (isCommentsEnabled()) {
367
- commentsEnabled = true
368
- const mod = await import('./CommentsMenuButton.svelte')
369
- CommentsMenuButton = mod.default
370
- }
371
- } catch {}
413
+ // Run setup
414
+ if (mod.setup) {
415
+ const setupResult = await mod.setup(toolCtx)
416
+ if (setupResult) {
417
+ toolData[toolId] = setupResult
418
+ }
419
+ }
372
420
 
373
- // Load create menu features
374
- const createMenuConfig = allMenus.create
375
- try {
376
- if (createMenuConfig) {
377
- const { features } = await import('./workshop/features/registry.js')
378
-
379
- const createActions = Array.isArray(createMenuConfig.actions) ? createMenuConfig.actions : []
380
- createMenuFeatures = createActions
381
- .filter((a: any) => a.feature)
382
- .map((a: any) => {
383
- const feat = (features as Record<string, any>)[a.feature]
384
- if (!feat || !feat.overlayId || !feat.overlay) return null
385
- return {
386
- name: feat.name,
387
- label: a.label || feat.label,
388
- overlayId: feat.overlayId,
389
- overlay: feat.overlay,
390
- }
391
- })
392
- .filter(Boolean)
421
+ // Register handler as command action
422
+ if (mod.handler) {
423
+ const handlerResult = await mod.handler(toolCtx)
424
+ const actionId = toolConfig.handler || `core:${toolId}`
425
+ // Store handler result in toolData for component access
426
+ if (handlerResult && !handlerResult.getChildren) {
427
+ toolData[toolId] = { ...(toolData[toolId] || {}), ...handlerResult }
428
+ }
429
+ registerCommandAction(actionId, handlerResult)
430
+ }
393
431
 
394
- if (createMenuFeatures.length > 0) {
395
- const mod = await import('./CreateMenuButton.svelte')
396
- CreateMenuButton = mod.default
432
+ // Load component
433
+ if (mod.component) {
434
+ const component = await mod.component()
435
+ toolComponents[toolId] = component
397
436
  }
398
- }
399
- } catch {}
437
+ } catch { /* tool failed to load — skip gracefully */ }
438
+ }
400
439
 
401
440
  // Load side panel component
402
441
  try {
@@ -406,12 +445,6 @@
406
445
  }
407
446
  } catch {}
408
447
 
409
- // Load canvas create menu
410
- try {
411
- const mod = await import('./CanvasCreateMenu.svelte')
412
- CanvasCreateMenu = mod.default
413
- } catch {}
414
-
415
448
  // Listen for canvas mount/unmount events (React↔Svelte bridge)
416
449
  document.addEventListener('storyboard:canvas:mounted', handleCanvasMounted)
417
450
  document.addEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
@@ -445,20 +478,6 @@
445
478
  canvasZoom = (e as CustomEvent).detail?.zoom ?? canvasZoom
446
479
  }
447
480
 
448
- function canvasZoomIn() {
449
- const next = Math.min(ZOOM_MAX, canvasZoom + ZOOM_STEP)
450
- document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
451
- }
452
-
453
- function canvasZoomOut() {
454
- const next = Math.max(ZOOM_MIN, canvasZoom - ZOOM_STEP)
455
- document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
456
- }
457
-
458
- function canvasZoomReset() {
459
- document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: 100 } }))
460
- }
461
-
462
481
  // Flow info dialog state — driven by core/show-flow-info action
463
482
  let flowDialogOpen = $state(false)
464
483
  let flowName = $state('default')
@@ -474,41 +493,29 @@
474
493
  </script>
475
494
 
476
495
  {#if !isEmbed}
477
- {#if visible && canvasActive && CanvasCreateMenu}
496
+ {#if visible && canvasActive && canvasMenus.length > 0}
478
497
  <div
479
498
  class="fixed bottom-6 left-6 z-[9999] font-sans flex items-center gap-3"
480
499
  role="toolbar"
481
500
  aria-label="Canvas toolbar"
482
501
  >
483
- <Tooltip.Root>
484
- <Tooltip.Trigger>
485
- <CanvasCreateMenu config={canvasToolbarConfig} canvasName={activeCanvasName} tabindex={0} />
486
- </Tooltip.Trigger>
487
- <Tooltip.Content side="top">Add widget to canvas</Tooltip.Content>
488
- </Tooltip.Root>
489
-
490
- <div class="canvas-zoom-bar">
491
- <button
492
- class="canvas-zoom-btn"
493
- onclick={canvasZoomOut}
494
- disabled={canvasZoom <= ZOOM_MIN}
495
- aria-label="Zoom out"
496
- title="Zoom out"
497
- >−</button>
498
- <button
499
- class="canvas-zoom-label"
500
- onclick={canvasZoomReset}
501
- aria-label="Reset zoom to 100%"
502
- title="Reset to 100%"
503
- >{canvasZoom}%</button>
504
- <button
505
- class="canvas-zoom-btn"
506
- onclick={canvasZoomIn}
507
- disabled={canvasZoom >= ZOOM_MAX}
508
- aria-label="Zoom in"
509
- title="Zoom in"
510
- >+</button>
511
- </div>
502
+ {#each canvasMenus as canvasTool (canvasTool.key)}
503
+ {#if toolComponents[canvasTool.key]}
504
+ {@const CanvasToolComponent = toolComponents[canvasTool.key]}
505
+ <Tooltip.Root>
506
+ <Tooltip.Trigger>
507
+ <CanvasToolComponent
508
+ config={canvasTool}
509
+ data={toolData[canvasTool.key]}
510
+ canvasName={activeCanvasName}
511
+ zoom={canvasZoom}
512
+ tabindex={0}
513
+ />
514
+ </Tooltip.Trigger>
515
+ <Tooltip.Content side="top">{canvasTool.ariaLabel || canvasTool.key}</Tooltip.Content>
516
+ </Tooltip.Root>
517
+ {/if}
518
+ {/each}
512
519
  </div>
513
520
  {/if}
514
521
  <div
@@ -522,32 +529,47 @@
522
529
  bind:this={toolbarEl}
523
530
  >
524
531
  {#if visible}
525
- {#each visibleMenus as menu, i (menu.key)}
526
- <Tooltip.Root>
527
- <Tooltip.Trigger>
528
- {#if menu.sidepanel}
529
- <TriggerButton
530
- active={$sidePanelState.open && $sidePanelState.activeTab === menu.sidepanel}
531
- size="icon-xl"
532
- aria-label={menu.ariaLabel || menu.key}
533
- tabindex={getTabindex(i)}
534
- onfocus={() => { activeToolbarIndex = i }}
535
- onclick={() => togglePanel(menu.sidepanel)}
536
- >
537
- <Icon name={menu.icon || menu.key} size={16} {...(menu.meta || {})} />
538
- </TriggerButton>
539
- {:else if menu.action}
540
- <ActionMenuButton config={menu} tabindex={getTabindex(i)} />
541
- {:else if menu.key === 'create'}
542
- <CreateMenuButton features={createMenuFeatures} config={menu} tabindex={getTabindex(i)} />
543
- {:else if menu.key === 'comments'}
544
- <CommentsMenuButton config={menu} tabindex={getTabindex(i)} />
545
- {:else if menu.key === 'theme'}
546
- <ThemeMenuButton config={menu} tabindex={getTabindex(i)} />
547
- {/if}
548
- </Tooltip.Trigger>
549
- <Tooltip.Content side="top">{menu.ariaLabel || menu.key}</Tooltip.Content>
550
- </Tooltip.Root>
532
+ {#each cleanedMenus as menu, i (menu.key)}
533
+ {#if menu.render === 'separator'}
534
+ <div class="toolbar-separator" aria-hidden="true"></div>
535
+ {:else}
536
+ <Tooltip.Root>
537
+ <Tooltip.Trigger>
538
+ {#if menu.render === 'sidepanel'}
539
+ {@const toolState = getToolbarToolState(menu.key)}
540
+ <TriggerButton
541
+ active={$sidePanelState.open && $sidePanelState.activeTab === menu.sidepanel}
542
+ inactive={toolState === 'inactive'}
543
+ dimmed={toolState === 'dimmed'}
544
+ localOnly={isToolbarToolLocalOnly(menu.key)}
545
+ size="icon-xl"
546
+ aria-label={menu.ariaLabel || menu.key}
547
+ tabindex={getTabindex(i)}
548
+ onfocus={() => { activeToolbarIndex = i }}
549
+ onclick={() => togglePanel(menu.sidepanel)}
550
+ >
551
+ <Icon name={menu.icon || menu.key} size={16} {...(menu.meta || {})} />
552
+ </TriggerButton>
553
+ {:else if toolComponents[menu.key]}
554
+ {@const toolState = getToolbarToolState(menu.key)}
555
+ {@const ToolComponent = toolComponents[menu.key]}
556
+ <span
557
+ data-tool-state={toolState}
558
+ data-local-only={isToolbarToolLocalOnly(menu.key) || undefined}
559
+ class={toolState === 'inactive' ? 'tool-inactive' : toolState === 'dimmed' ? 'tool-dimmed' : ''}
560
+ >
561
+ <ToolComponent
562
+ config={menu}
563
+ data={toolData[menu.key]}
564
+ tabindex={getTabindex(i)}
565
+ localOnly={isToolbarToolLocalOnly(menu.key)}
566
+ />
567
+ </span>
568
+ {/if}
569
+ </Tooltip.Trigger>
570
+ <Tooltip.Content side="top">{menu.ariaLabel || menu.key}</Tooltip.Content>
571
+ </Tooltip.Root>
572
+ {/if}
551
573
  {/each}
552
574
  {/if}
553
575
  {#if commandMenuConfig}
@@ -568,68 +590,52 @@
568
590
  {/if}
569
591
 
570
592
  <style>
571
- .canvas-zoom-bar {
572
- display: flex;
573
- align-items: center;
574
- border-radius: 10px;
575
- border: 1.5px solid var(--trigger-border, var(--color-slate-400));
576
- background: var(--trigger-bg, var(--color-slate-100));
577
- overflow: hidden;
578
- }
579
-
580
- .canvas-zoom-btn {
581
- all: unset;
582
- cursor: pointer;
583
- display: flex;
584
- align-items: center;
585
- justify-content: center;
586
- width: 36px;
587
- height: 32px;
588
- font-size: 16px;
589
- font-weight: 600;
590
- color: var(--trigger-text, var(--color-slate-600));
591
- transition: background 120ms;
593
+ .toolbar-separator {
594
+ width: 1px;
595
+ height: 20px;
596
+ background: var(--trigger-border, var(--color-slate-400));
597
+ opacity: 0.4;
598
+ flex-shrink: 0;
592
599
  }
593
600
 
594
- .canvas-zoom-btn:hover:not(:disabled) {
595
- background: var(--trigger-bg-hover, var(--color-slate-300));
596
- }
597
-
598
- .canvas-zoom-btn:disabled {
601
+ .default-button-dimmed {
599
602
  opacity: 0.3;
600
- cursor: default;
603
+ transition: opacity 200ms;
601
604
  }
602
605
 
603
- .canvas-zoom-label {
604
- all: unset;
605
- cursor: pointer;
606
- display: flex;
607
- align-items: center;
608
- justify-content: center;
609
- min-width: 48px;
610
- height: 32px;
611
- padding: 0 4px;
612
- font-size: 11px;
613
- font-weight: 600;
614
- font-variant-numeric: tabular-nums;
615
- color: var(--trigger-text, var(--color-slate-600));
616
- border-left: 1.5px solid var(--trigger-border, var(--color-slate-400));
617
- border-right: 1.5px solid var(--trigger-border, var(--color-slate-400));
618
- transition: background 120ms;
606
+ .default-button-dimmed:hover,
607
+ .default-button-dimmed:focus-within {
608
+ opacity: 1;
619
609
  }
620
610
 
621
- .canvas-zoom-label:hover {
622
- background: var(--trigger-bg-hover, var(--color-slate-300));
611
+ .tool-inactive {
612
+ opacity: 0.45;
613
+ pointer-events: none;
623
614
  }
624
-
625
- .default-button-dimmed {
615
+ .tool-dimmed {
626
616
  opacity: 0.3;
627
617
  transition: opacity 200ms;
628
618
  }
629
-
630
- .default-button-dimmed:hover,
631
- .default-button-dimmed:focus-within {
619
+ .tool-dimmed:hover,
620
+ .tool-dimmed:focus-within {
632
621
  opacity: 1;
633
622
  }
623
+ [data-local-only] {
624
+ position: relative;
625
+ }
626
+ [data-local-only]::after {
627
+ content: '';
628
+ position: absolute;
629
+ top: -1px;
630
+ right: -1px;
631
+ width: 8px;
632
+ height: 8px;
633
+ background: hsl(137, 66%, 30%);
634
+ border-radius: 50%;
635
+ border: 2px solid var(--sc-border-color, transparent);
636
+ box-sizing: content-box;
637
+ pointer-events: none;
638
+ z-index: 1;
639
+ }
634
640
  </style>
635
641