@dfosco/storyboard-core 3.3.2 → 3.5.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 (51) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +14899 -11508
  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 +350 -347
  11. package/src/CreateMenuButton.svelte +6 -2
  12. package/src/InspectorPanel.svelte +123 -59
  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 +257 -33
  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/sidepanel.css +2 -2
  29. package/src/stores/themeStore.ts +66 -0
  30. package/src/svelte-plugin-ui/components/Viewfinder.svelte +16 -11
  31. package/src/toolRegistry.js +226 -0
  32. package/src/toolStateStore.js +180 -0
  33. package/src/toolStateStore.test.js +204 -0
  34. package/src/toolbarConfigStore.js +135 -0
  35. package/src/tools/handlers/canvasAddWidget.js +11 -0
  36. package/src/tools/handlers/canvasZoom.js +34 -0
  37. package/src/tools/handlers/comments.js +16 -0
  38. package/src/tools/handlers/create.js +39 -0
  39. package/src/tools/handlers/devtools.js +80 -0
  40. package/src/tools/handlers/docs.js +11 -0
  41. package/src/tools/handlers/featureFlags.js +21 -0
  42. package/src/tools/handlers/flows.js +62 -0
  43. package/src/tools/handlers/inspector.js +19 -0
  44. package/src/tools/handlers/theme.js +9 -0
  45. package/src/tools/registry.js +21 -0
  46. package/src/tools/surfaces/canvasToolbar.js +10 -0
  47. package/src/tools/surfaces/commandList.js +10 -0
  48. package/src/tools/surfaces/mainToolbar.js +11 -0
  49. package/src/tools/surfaces/registry.js +19 -0
  50. package/src/vite/server-plugin.js +36 -6
  51. package/toolbar.config.json +101 -48
@@ -10,69 +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
80
  const isLocalDev = typeof window !== 'undefined' && (window as any).__SB_LOCAL_DEV__ === true
61
81
 
62
- const commandMenuConfig = $derived(isMenuHidden('command') ? null : config.menus?.command)
63
- const shortcutsConfig = $derived((config as any).shortcuts || {})
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
+ })
64
166
 
65
167
  // Build ordered menu list from JSON key order (excluding command, which is always rightmost)
66
- const allMenus = $derived((config.menus || {}) as Record<string, any>)
168
+ const allMenus = $derived(resolveMenus(config))
67
169
  const orderedMenus = $derived(Object.entries(allMenus)
68
170
  .filter(([key]) => key !== 'command')
69
171
  .filter(([key]) => !isMenuHidden(key))
70
- .filter(([, menu]) => !menu.localOnly || isLocalDev)
172
+ .filter(([key]) => {
173
+ void toolStateVersion
174
+ return getToolbarToolState(key) !== 'disabled'
175
+ })
71
176
  .map(([key, menu]) => ({ key, ...menu })))
72
177
 
73
178
  // Discover menus with sidepanel property
74
179
  const sidepanelMenus = $derived(orderedMenus.filter(menu => menu.sidepanel))
75
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
+
76
191
  function menuVisibleInMode(menu: any, mode: string): boolean {
77
192
  if (!menu?.modes) return false
78
193
  if (isExcludedByRoute(menu)) return false
@@ -84,21 +199,51 @@
84
199
  orderedMenus
85
200
  .filter(menu => {
86
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
87
206
  if (!menuVisibleInMode(menu, $modeState.mode)) return false
88
- if (menu.action) return ActionMenuButton && getActionChildren(menu.action).length > 0
89
- if (menu.key === 'create') return CreateMenuButton && createMenuFeatures.length > 0
90
- if (menu.key === 'comments') return CommentsMenuButton && commentsEnabled
91
- 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
+ }
92
216
  return true
93
217
  })
94
218
  .reverse()
95
219
  )
96
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
+
97
238
  // Total toolbar item count (visible menus + command menu if present)
98
- const toolbarItemCount = $derived(visibleMenus.length + (commandMenuConfig ? 1 : 0))
239
+ const toolbarItemCount = $derived(
240
+ cleanedMenus.filter(m => m.render !== 'separator').length + (commandMenuConfig ? 1 : 0)
241
+ )
99
242
 
100
243
  // Command menu is always the last item (rightmost)
101
- const commandMenuIndex = $derived(commandMenuConfig ? visibleMenus.length : -1)
244
+ const commandMenuIndex = $derived(
245
+ commandMenuConfig ? cleanedMenus.filter(m => m.render !== 'separator').length : -1
246
+ )
102
247
 
103
248
  function getTabindex(index: number): number {
104
249
  if (activeToolbarIndex < 0) {
@@ -174,20 +319,19 @@
174
319
  e.preventDefault()
175
320
  commandMenuOpen = !commandMenuOpen
176
321
  }
177
- // Cmd+D toggle documentation panel
178
- if (e.key === 'd' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
179
- const docsMenu = visibleMenus.find(m => m.sidepanel === 'docs')
180
- if (docsMenu) {
181
- e.preventDefault()
182
- togglePanel('docs')
183
- }
184
- }
185
- // Cmd+I — toggle inspector panel
186
- if (e.key === 'i' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
187
- const inspectorMenu = visibleMenus.find(m => m.sidepanel === 'inspector')
188
- if (inspectorMenu) {
189
- e.preventDefault()
190
- 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
191
335
  }
192
336
  }
193
337
  }
@@ -196,210 +340,102 @@
196
340
  window.addEventListener('keydown', handleKeydown)
197
341
  setRoutingBasePath(basePath)
198
342
 
199
- // Re-evaluate action menus on SPA navigation
200
- 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() }
201
365
  window.addEventListener('popstate', bumpNav)
202
366
  origPushState = history.pushState.bind(history)
203
367
  history.pushState = (...args: any[]) => { origPushState(...args); bumpNav() }
204
368
  origReplaceState = history.replaceState.bind(history)
205
369
  history.replaceState = (...args: any[]) => { origReplaceState(...args); bumpNav() }
206
370
 
371
+ // Apply prototype toolbar config for the initial route
372
+ syncPrototypeToolbar()
373
+
207
374
  // Seed the command action registry from config
208
375
  if (commandMenuConfig) {
209
376
  initCommandActions(commandMenuConfig)
210
377
  }
211
378
 
212
- // Register core action handlers
213
- registerCommandAction('core/viewfinder', () => {
214
- window.location.href = basePath + 'viewfinder'
215
- })
216
-
217
379
  // Register sidepanel toggle actions
218
380
  for (const menu of sidepanelMenus) {
219
- registerCommandAction(`core/${menu.key}`, () => {
381
+ registerCommandAction(`core:${menu.key}`, () => {
220
382
  togglePanel(menu.sidepanel)
221
383
  })
222
384
  }
223
385
 
224
- // Auto-open inspector panel if ?inspect= param is in the URL
225
- try {
226
- const inspectParam = new URL(window.location.href).searchParams.get('inspect')
227
- if (inspectParam) {
228
- openPanel('inspector')
229
- }
230
- } 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 }
231
390
 
232
- // Register devtools submenu (show flow info, reset params, hide mode, logout)
233
- {
234
- let loader: any = null
235
- let hm: any = null
236
- let commentsAuth: any = null
237
- try { loader = await import('./loader.js') } catch {}
238
- try { hm = await import('./hideMode.js') } catch {}
239
- try { commentsAuth = await import('./comments/auth.js') } catch {}
240
-
241
- registerCommandAction('core/devtools', {
242
- getChildren: () => {
243
- const children: any[] = []
244
- if (loader) {
245
- children.push({
246
- id: 'core/show-flow-info',
247
- label: 'Show flow info',
248
- type: 'default',
249
- execute: () => {
250
- const p = new URLSearchParams(window.location.search)
251
- const name = p.get('flow') || p.get('scene') || 'default'
252
- try {
253
- const data = loader.loadFlow(name)
254
- showFlowInfoDialog(name, JSON.stringify(data, null, 2), null)
255
- } catch (e: any) {
256
- showFlowInfoDialog(name, '', e.message)
257
- }
258
- },
259
- })
260
- }
261
- children.push({
262
- id: 'core/reset-params',
263
- label: 'Reset all params',
264
- type: 'default',
265
- execute: () => { window.location.hash = '' },
266
- })
267
- if (hm) {
268
- children.push({
269
- id: 'core/hide-mode',
270
- label: 'Hide mode',
271
- type: 'toggle',
272
- active: hm.isHideMode(),
273
- execute: () => {
274
- if (hm.isHideMode()) hm.deactivateHideMode()
275
- else hm.activateHideMode()
276
- },
277
- })
278
- }
279
- if (commentsAuth?.isAuthenticated()) {
280
- children.push({
281
- id: 'core/logout',
282
- label: 'Logout (remove token)',
283
- type: 'default',
284
- execute: () => {
285
- commentsAuth.clearToken()
286
- console.log('[storyboard] Token removed')
287
- },
288
- })
289
- }
290
- return children
291
- },
292
- })
293
- }
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
294
394
 
295
- try {
296
- const ff = await import('./featureFlags.js')
297
- registerCommandAction('core/feature-flags', {
298
- getChildren: () =>
299
- ff.getFlagKeys().map((key: string) => ({
300
- id: `flags/${key}`,
301
- label: key,
302
- type: 'toggle' as const,
303
- active: ff.getFlag(key),
304
- execute: () => ff.toggleFlag(key),
305
- })),
306
- })
307
- } catch {}
395
+ // Skip disabled tools — don't load their modules at all
396
+ if (getToolbarToolState(toolId) === 'disabled') continue
308
397
 
309
- // Register flow switcher action (dynamic — reads current prototype from URL)
310
- try {
311
- const loader = await import('./loader.js')
312
- const vf = await import('./viewfinder.js')
313
-
314
- registerCommandAction('core/flows', {
315
- getChildren: () => {
316
- let path = window.location.pathname
317
- const base = basePath.replace(/\/+$/, '')
318
- if (base && path.startsWith(base)) path = path.slice(base.length)
319
- path = path.replace(/\/+$/, '') || '/'
320
- const segments = path.split('/').filter(Boolean)
321
- const proto = segments[0] || null
322
- if (!proto) return []
323
-
324
- // Detect active flow
325
- const params = new URLSearchParams(window.location.search)
326
- const explicit = params.get('flow') || params.get('scene')
327
- let active: string
328
- if (explicit) {
329
- active = loader.resolveFlowName(proto, explicit)
330
- } else {
331
- const pageFlow = path === '/' ? 'index' : (path.split('/').pop() || 'index')
332
- const scoped = loader.resolveFlowName(proto, pageFlow)
333
- if (loader.flowExists(scoped)) active = scoped
334
- else {
335
- const protoFlow = loader.resolveFlowName(proto, proto)
336
- active = loader.flowExists(protoFlow) ? protoFlow : 'default'
337
- }
338
- }
339
-
340
- return loader.getFlowsForPrototype(proto).map((f: any) => {
341
- const meta = vf.getFlowMeta(f.key)
342
- return {
343
- id: f.key,
344
- label: meta?.title || f.name,
345
- type: 'radio' as const,
346
- active: f.key === active,
347
- execute: () => { window.location.href = vf.resolveFlowRoute(f.key) },
348
- }
349
- })
350
- },
351
- })
352
- } 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
353
402
 
354
- // Load action menu button (used for any menu with an "action" reference)
355
- try {
356
- const mod = await import('./ActionMenuButton.svelte')
357
- ActionMenuButton = mod.default
358
- } catch {}
403
+ try {
404
+ const mod = await loadModule()
405
+ const toolCtx = { ...ctx, config: toolConfig }
359
406
 
360
- // Load theme menu button
361
- try {
362
- const mod = await import('./ThemeMenuButton.svelte')
363
- ThemeMenuButton = mod.default
364
- } 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
+ }
365
412
 
366
- // Load comments menu button
367
- try {
368
- const { isCommentsEnabled } = await import('./comments/config.js')
369
- if (isCommentsEnabled()) {
370
- commentsEnabled = true
371
- const mod = await import('./CommentsMenuButton.svelte')
372
- CommentsMenuButton = mod.default
373
- }
374
- } catch {}
413
+ // Run setup
414
+ if (mod.setup) {
415
+ const setupResult = await mod.setup(toolCtx)
416
+ if (setupResult) {
417
+ toolData[toolId] = setupResult
418
+ }
419
+ }
375
420
 
376
- // Load create menu features
377
- const createMenuConfig = allMenus.create
378
- try {
379
- if (createMenuConfig) {
380
- const { features } = await import('./workshop/features/registry.js')
381
-
382
- const createActions = Array.isArray(createMenuConfig.actions) ? createMenuConfig.actions : []
383
- createMenuFeatures = createActions
384
- .filter((a: any) => a.feature)
385
- .map((a: any) => {
386
- const feat = (features as Record<string, any>)[a.feature]
387
- if (!feat || !feat.overlayId || !feat.overlay) return null
388
- return {
389
- name: feat.name,
390
- label: a.label || feat.label,
391
- overlayId: feat.overlayId,
392
- overlay: feat.overlay,
393
- }
394
- })
395
- .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
+ }
396
431
 
397
- if (createMenuFeatures.length > 0) {
398
- const mod = await import('./CreateMenuButton.svelte')
399
- CreateMenuButton = mod.default
432
+ // Load component
433
+ if (mod.component) {
434
+ const component = await mod.component()
435
+ toolComponents[toolId] = component
400
436
  }
401
- }
402
- } catch {}
437
+ } catch { /* tool failed to load — skip gracefully */ }
438
+ }
403
439
 
404
440
  // Load side panel component
405
441
  try {
@@ -409,12 +445,6 @@
409
445
  }
410
446
  } catch {}
411
447
 
412
- // Load canvas create menu
413
- try {
414
- const mod = await import('./CanvasCreateMenu.svelte')
415
- CanvasCreateMenu = mod.default
416
- } catch {}
417
-
418
448
  // Listen for canvas mount/unmount events (React↔Svelte bridge)
419
449
  document.addEventListener('storyboard:canvas:mounted', handleCanvasMounted)
420
450
  document.addEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
@@ -448,20 +478,6 @@
448
478
  canvasZoom = (e as CustomEvent).detail?.zoom ?? canvasZoom
449
479
  }
450
480
 
451
- function canvasZoomIn() {
452
- const next = Math.min(ZOOM_MAX, canvasZoom + ZOOM_STEP)
453
- document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
454
- }
455
-
456
- function canvasZoomOut() {
457
- const next = Math.max(ZOOM_MIN, canvasZoom - ZOOM_STEP)
458
- document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
459
- }
460
-
461
- function canvasZoomReset() {
462
- document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: 100 } }))
463
- }
464
-
465
481
  // Flow info dialog state — driven by core/show-flow-info action
466
482
  let flowDialogOpen = $state(false)
467
483
  let flowName = $state('default')
@@ -477,41 +493,29 @@
477
493
  </script>
478
494
 
479
495
  {#if !isEmbed}
480
- {#if visible && canvasActive && CanvasCreateMenu}
496
+ {#if visible && canvasActive && canvasMenus.length > 0}
481
497
  <div
482
498
  class="fixed bottom-6 left-6 z-[9999] font-sans flex items-center gap-3"
483
499
  role="toolbar"
484
500
  aria-label="Canvas toolbar"
485
501
  >
486
- <Tooltip.Root>
487
- <Tooltip.Trigger>
488
- <CanvasCreateMenu config={canvasToolbarConfig} canvasName={activeCanvasName} tabindex={0} />
489
- </Tooltip.Trigger>
490
- <Tooltip.Content side="top">Add widget to canvas</Tooltip.Content>
491
- </Tooltip.Root>
492
-
493
- <div class="canvas-zoom-bar">
494
- <button
495
- class="canvas-zoom-btn"
496
- onclick={canvasZoomOut}
497
- disabled={canvasZoom <= ZOOM_MIN}
498
- aria-label="Zoom out"
499
- title="Zoom out"
500
- >−</button>
501
- <button
502
- class="canvas-zoom-label"
503
- onclick={canvasZoomReset}
504
- aria-label="Reset zoom to 100%"
505
- title="Reset to 100%"
506
- >{canvasZoom}%</button>
507
- <button
508
- class="canvas-zoom-btn"
509
- onclick={canvasZoomIn}
510
- disabled={canvasZoom >= ZOOM_MAX}
511
- aria-label="Zoom in"
512
- title="Zoom in"
513
- >+</button>
514
- </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}
515
519
  </div>
516
520
  {/if}
517
521
  <div
@@ -525,32 +529,47 @@
525
529
  bind:this={toolbarEl}
526
530
  >
527
531
  {#if visible}
528
- {#each visibleMenus as menu, i (menu.key)}
529
- <Tooltip.Root>
530
- <Tooltip.Trigger>
531
- {#if menu.sidepanel}
532
- <TriggerButton
533
- active={$sidePanelState.open && $sidePanelState.activeTab === menu.sidepanel}
534
- size="icon-xl"
535
- aria-label={menu.ariaLabel || menu.key}
536
- tabindex={getTabindex(i)}
537
- onfocus={() => { activeToolbarIndex = i }}
538
- onclick={() => togglePanel(menu.sidepanel)}
539
- >
540
- <Icon name={menu.icon || menu.key} size={16} {...(menu.meta || {})} />
541
- </TriggerButton>
542
- {:else if menu.action}
543
- <ActionMenuButton config={menu} tabindex={getTabindex(i)} />
544
- {:else if menu.key === 'create'}
545
- <CreateMenuButton features={createMenuFeatures} config={menu} tabindex={getTabindex(i)} />
546
- {:else if menu.key === 'comments'}
547
- <CommentsMenuButton config={menu} tabindex={getTabindex(i)} />
548
- {:else if menu.key === 'theme'}
549
- <ThemeMenuButton config={menu} tabindex={getTabindex(i)} />
550
- {/if}
551
- </Tooltip.Trigger>
552
- <Tooltip.Content side="top">{menu.ariaLabel || menu.key}</Tooltip.Content>
553
- </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}
554
573
  {/each}
555
574
  {/if}
556
575
  {#if commandMenuConfig}
@@ -571,68 +590,52 @@
571
590
  {/if}
572
591
 
573
592
  <style>
574
- .canvas-zoom-bar {
575
- display: flex;
576
- align-items: center;
577
- border-radius: 10px;
578
- border: 1.5px solid var(--trigger-border, var(--color-slate-400));
579
- background: var(--trigger-bg, var(--color-slate-100));
580
- overflow: hidden;
581
- }
582
-
583
- .canvas-zoom-btn {
584
- all: unset;
585
- cursor: pointer;
586
- display: flex;
587
- align-items: center;
588
- justify-content: center;
589
- width: 36px;
590
- height: 32px;
591
- font-size: 16px;
592
- font-weight: 600;
593
- color: var(--trigger-text, var(--color-slate-600));
594
- 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;
595
599
  }
596
600
 
597
- .canvas-zoom-btn:hover:not(:disabled) {
598
- background: var(--trigger-bg-hover, var(--color-slate-300));
599
- }
600
-
601
- .canvas-zoom-btn:disabled {
601
+ .default-button-dimmed {
602
602
  opacity: 0.3;
603
- cursor: default;
603
+ transition: opacity 200ms;
604
604
  }
605
605
 
606
- .canvas-zoom-label {
607
- all: unset;
608
- cursor: pointer;
609
- display: flex;
610
- align-items: center;
611
- justify-content: center;
612
- min-width: 48px;
613
- height: 32px;
614
- padding: 0 4px;
615
- font-size: 11px;
616
- font-weight: 600;
617
- font-variant-numeric: tabular-nums;
618
- color: var(--trigger-text, var(--color-slate-600));
619
- border-left: 1.5px solid var(--trigger-border, var(--color-slate-400));
620
- border-right: 1.5px solid var(--trigger-border, var(--color-slate-400));
621
- transition: background 120ms;
606
+ .default-button-dimmed:hover,
607
+ .default-button-dimmed:focus-within {
608
+ opacity: 1;
622
609
  }
623
610
 
624
- .canvas-zoom-label:hover {
625
- background: var(--trigger-bg-hover, var(--color-slate-300));
611
+ .tool-inactive {
612
+ opacity: 0.45;
613
+ pointer-events: none;
626
614
  }
627
-
628
- .default-button-dimmed {
615
+ .tool-dimmed {
629
616
  opacity: 0.3;
630
617
  transition: opacity 200ms;
631
618
  }
632
-
633
- .default-button-dimmed:hover,
634
- .default-button-dimmed:focus-within {
619
+ .tool-dimmed:hover,
620
+ .tool-dimmed:focus-within {
635
621
  opacity: 1;
636
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
+ }
637
640
  </style>
638
641