@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
@@ -1,43 +1,267 @@
1
1
  /**
2
- * Lightweight shiki highlighter for the inspector panel.
2
+ * Lightweight highlight.js highlighter for the inspector panel.
3
3
  *
4
- * Uses shiki/core with only the four languages the inspector needs,
5
- * avoiding the full shiki bundle that registers 200+ lazy-loaded
6
- * language chunks (which break in deployed/static environments).
4
+ * Uses highlight.js/core with only the languages the inspector needs
5
+ * (javascript, typescript, xml for JSX), producing small bundles with
6
+ * no WASM dependencies.
7
7
  *
8
- * Import specifiers are computed via template literals so consumer
9
- * bundlers (Rollup, esbuild) can't statically analyze them they
10
- * skip dynamic imports with variable specifiers instead of erroring.
11
- * Returns null when shiki is unavailable.
8
+ * Theme is resolved at render time via toolbar.config.json's
9
+ * `highlighting` key and the theme sync settings. Colors are applied
10
+ * as inline styles no global CSS injection, no theme conflicts.
12
11
  */
13
12
 
14
- // Variable indirection prevents any bundler from statically resolving
15
- const SHIKI = 'shiki'
13
+ import hljs from 'highlight.js/lib/core'
14
+ import javascript from 'highlight.js/lib/languages/javascript'
15
+ import typescript from 'highlight.js/lib/languages/typescript'
16
+ import xml from 'highlight.js/lib/languages/xml'
17
+ import { getToolbarConfig } from '../toolbarConfigStore.js'
16
18
 
17
- export async function createInspectorHighlighter() {
18
- try {
19
- const [shikiCore, oniguruma, tsx, jsx, javascript, typescript, githubDark, wasm] =
20
- await Promise.all([
21
- import(/* @vite-ignore */ `${SHIKI}/core`).catch(() => null),
22
- import(/* @vite-ignore */ `${SHIKI}/engine/oniguruma`).catch(() => null),
23
- import(/* @vite-ignore */ `${SHIKI}/dist/langs/tsx.mjs`).catch(() => null),
24
- import(/* @vite-ignore */ `${SHIKI}/dist/langs/jsx.mjs`).catch(() => null),
25
- import(/* @vite-ignore */ `${SHIKI}/dist/langs/javascript.mjs`).catch(() => null),
26
- import(/* @vite-ignore */ `${SHIKI}/dist/langs/typescript.mjs`).catch(() => null),
27
- import(/* @vite-ignore */ `${SHIKI}/dist/themes/github-dark.mjs`).catch(() => null),
28
- import(/* @vite-ignore */ `${SHIKI}/wasm`).catch(() => null),
29
- ])
30
-
31
- if (!shikiCore || !oniguruma || !tsx || !jsx || !javascript || !typescript || !githubDark || !wasm) {
32
- return null
19
+ hljs.registerLanguage('javascript', javascript)
20
+ hljs.registerLanguage('typescript', typescript)
21
+ hljs.registerLanguage('xml', xml)
22
+ hljs.registerLanguage('jsx', javascript)
23
+ hljs.registerLanguage('tsx', typescript)
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Color palettes inline styles, no external CSS needed
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const THEMES = {
30
+ 'github-dark-dimmed': {
31
+ bg: '#22272e',
32
+ fg: '#adbac7',
33
+ headerBg: '#2d333b',
34
+ headerFg: '#768390',
35
+ border: '#373e47',
36
+ lineHighlight: 'rgba(99, 110, 123, 0.15)',
37
+ linkHover: '#adbac7',
38
+ keyword: '#f47067',
39
+ string: '#96d0ff',
40
+ number: '#6cb6ff',
41
+ comment: '#768390',
42
+ function: '#dcbdfb',
43
+ title: '#dcbdfb',
44
+ built_in: '#6cb6ff',
45
+ literal: '#6cb6ff',
46
+ type: '#6cb6ff',
47
+ attr: '#6cb6ff',
48
+ tag: '#8ddb8c',
49
+ name: '#8ddb8c',
50
+ attribute: '#6cb6ff',
51
+ variable: '#f69d50',
52
+ 'template-variable': '#f69d50',
53
+ params: '#adbac7',
54
+ meta: '#768390',
55
+ regexp: '#96d0ff',
56
+ symbol: '#6cb6ff',
57
+ operator: '#adbac7',
58
+ punctuation: '#adbac7',
59
+ selector: '#8ddb8c',
60
+ property: '#6cb6ff',
61
+ },
62
+ 'github-dark': {
63
+ bg: '#0d1117',
64
+ fg: '#e6edf3',
65
+ headerBg: '#161b22',
66
+ headerFg: '#8b949e',
67
+ border: '#30363d',
68
+ lineHighlight: 'rgba(110, 118, 129, 0.15)',
69
+ linkHover: '#c9d1d9',
70
+ keyword: '#ff7b72',
71
+ string: '#a5d6ff',
72
+ number: '#79c0ff',
73
+ comment: '#8b949e',
74
+ function: '#d2a8ff',
75
+ title: '#d2a8ff',
76
+ built_in: '#79c0ff',
77
+ literal: '#79c0ff',
78
+ type: '#79c0ff',
79
+ attr: '#79c0ff',
80
+ tag: '#7ee787',
81
+ name: '#7ee787',
82
+ attribute: '#79c0ff',
83
+ variable: '#ffa657',
84
+ 'template-variable': '#ffa657',
85
+ params: '#e6edf3',
86
+ meta: '#8b949e',
87
+ regexp: '#a5d6ff',
88
+ symbol: '#79c0ff',
89
+ operator: '#e6edf3',
90
+ punctuation: '#e6edf3',
91
+ selector: '#7ee787',
92
+ property: '#79c0ff',
93
+ },
94
+ github: {
95
+ bg: '#ffffff',
96
+ fg: '#1f2328',
97
+ headerBg: '#f6f8fa',
98
+ headerFg: '#656d76',
99
+ border: '#d1d9e0',
100
+ lineHighlight: 'rgba(234, 179, 8, 0.12)',
101
+ linkHover: '#1f2328',
102
+ keyword: '#cf222e',
103
+ string: '#0a3069',
104
+ number: '#0550ae',
105
+ comment: '#6e7781',
106
+ function: '#8250df',
107
+ title: '#8250df',
108
+ built_in: '#0550ae',
109
+ literal: '#0550ae',
110
+ type: '#0550ae',
111
+ attr: '#0550ae',
112
+ tag: '#116329',
113
+ name: '#116329',
114
+ attribute: '#0550ae',
115
+ variable: '#953800',
116
+ 'template-variable': '#953800',
117
+ params: '#1f2328',
118
+ meta: '#6e7781',
119
+ regexp: '#0a3069',
120
+ symbol: '#0550ae',
121
+ operator: '#1f2328',
122
+ punctuation: '#1f2328',
123
+ selector: '#116329',
124
+ property: '#0550ae',
125
+ },
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Theme resolution
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Resolve the current theme ID based on page theme and config.
134
+ * Always follows the page theme (data-sb-theme attribute).
135
+ */
136
+ function normalizeThemeId(requested, mode) {
137
+ const fallback = mode === 'light' ? 'github' : 'github-dark-dimmed'
138
+ if (!requested || typeof requested !== 'string') return fallback
139
+ if (THEMES[requested]) return requested
140
+
141
+ const key = requested.trim().toLowerCase().replace(/[\s_]+/g, '-')
142
+ const aliases = {
143
+ github: 'github',
144
+ 'github-light': 'github',
145
+ light: 'github',
146
+ 'night-owl-light': 'github',
147
+ 'github-dark': 'github-dark',
148
+ dark: 'github-dark-dimmed',
149
+ 'dark-dimmed': 'github-dark-dimmed',
150
+ 'github-dark-dimmed': 'github-dark-dimmed',
151
+ 'night-owl': 'github-dark-dimmed',
152
+ }
153
+
154
+ const resolved = aliases[key]
155
+ if (resolved && THEMES[resolved]) return resolved
156
+ return fallback
157
+ }
158
+
159
+ /**
160
+ * Resolve the current theme ID based on page theme and config.
161
+ * Always follows the page theme (data-sb-theme attribute).
162
+ */
163
+ function resolveThemeId() {
164
+ const config = getToolbarConfig()
165
+ const highlighting = config?.highlighting || {}
166
+ const darkTheme = normalizeThemeId(highlighting.dark, 'dark')
167
+ const lightTheme = normalizeThemeId(highlighting.light, 'light')
168
+
169
+ const sbTheme = typeof document !== 'undefined'
170
+ ? document.documentElement.getAttribute('data-sb-theme') || 'dark'
171
+ : 'dark'
172
+
173
+ return sbTheme.startsWith('dark') ? darkTheme : lightTheme
174
+ }
175
+
176
+ /**
177
+ * Get the color palette for the current theme.
178
+ * Falls back to github-dark-dimmed for unknown theme names.
179
+ * Exported so InspectorPanel can use it for header/container colors.
180
+ */
181
+ export function getColors() {
182
+ const id = resolveThemeId()
183
+ return THEMES[id] || THEMES['github-dark-dimmed']
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // HTML generation
188
+ // ---------------------------------------------------------------------------
189
+
190
+ function escapeHtml(str) {
191
+ return str
192
+ .replace(/&/g, '&')
193
+ .replace(/</g, '&lt;')
194
+ .replace(/>/g, '&gt;')
195
+ .replace(/"/g, '&quot;')
196
+ }
197
+
198
+ /**
199
+ * Convert highlight.js class-based spans to inline-styled spans.
200
+ * highlight.js emits `<span class="hljs-keyword">` etc.
201
+ * We replace each with `<span style="color:...">` using the palette.
202
+ */
203
+ function applyInlineColors(html, colors) {
204
+ return html.replace(
205
+ /<span class="hljs-([^"]+)">/g,
206
+ (_, cls) => {
207
+ const color = colors[cls] || colors.fg
208
+ return `<span style="color:${color}">`
33
209
  }
210
+ )
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Public API
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Create the inspector highlighter.
219
+ * Returns an object with codeToHtml() matching the Shiki-compatible API.
220
+ */
221
+ export async function createInspectorHighlighter() {
222
+ return {
223
+ /**
224
+ * Highlight code and return HTML string with inline styles.
225
+ *
226
+ * @param {string} code - Source code to highlight
227
+ * @param {object} options
228
+ * @param {string} [options.lang] - Language identifier
229
+ * @param {string} [options.theme] - Ignored (theme resolved from config)
230
+ * @param {Array<{ start: { line: number }, end: { line: number }, properties: { class: string } }>} [options.decorations]
231
+ * @returns {string} HTML string with highlighted code
232
+ */
233
+ codeToHtml(code, options = {}) {
234
+ const lang = options.lang || 'javascript'
235
+ const decorations = options.decorations || []
236
+ const colors = getColors()
237
+
238
+ let highlighted
239
+ try {
240
+ highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
241
+ } catch {
242
+ highlighted = escapeHtml(code)
243
+ }
244
+
245
+ // Convert class-based spans to inline styles
246
+ highlighted = applyInlineColors(highlighted, colors)
247
+
248
+ const lines = highlighted.split('\n')
249
+ const highlightedLines = new Set()
250
+ for (const dec of decorations) {
251
+ if (dec.start && dec.properties?.class) {
252
+ for (let i = dec.start.line; i <= (dec.end?.line ?? dec.start.line); i++) {
253
+ highlightedLines.add(i)
254
+ }
255
+ }
256
+ }
257
+
258
+ const wrappedLines = lines.map((line, i) => {
259
+ const classes = ['line']
260
+ if (highlightedLines.has(i)) classes.push('highlighted-line')
261
+ return `<span class="${classes.join(' ')}">${line}</span>`
262
+ }).join('\n')
34
263
 
35
- return shikiCore.createHighlighterCore({
36
- themes: [githubDark.default],
37
- langs: [tsx.default, jsx.default, javascript.default, typescript.default],
38
- engine: oniguruma.createOnigurumaEngine(wasm),
39
- })
40
- } catch {
41
- return null
264
+ return `<pre style="background:${colors.bg};color:${colors.fg};margin:0;padding:0;overflow-x:auto"><code>${wrappedLines}</code></pre>`
265
+ },
42
266
  }
43
267
  }
@@ -36,7 +36,7 @@
36
36
  });
37
37
 
38
38
  export const wrapperVariants = tv({
39
- base: "rounded-lg inline-flex shrink-0 transition-transform",
39
+ base: "inline-flex shrink-0 transition-transform",
40
40
  variants: {
41
41
  size: {
42
42
  default: "h-8",
@@ -19,7 +19,7 @@
19
19
  {sideOffset}
20
20
  {align}
21
21
  class={cn(
22
- "font-sans data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 w-(--bits-dropdown-menu-anchor-width) data-closed:overflow-hidden z-[10000] bg-popover border-3 border-slate-400 text-slate-800 fill-slate-600 min-w-40 rounded-xl p-2 shadow-xl duration-100 overflow-x-hidden overflow-y-auto",
22
+ "font-sans data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 w-(--bits-dropdown-menu-anchor-width) data-closed:overflow-hidden z-[10000] bg-popover border-3 border-border text-popover-foreground fill-popover-foreground min-w-40 rounded-xl p-2 shadow-xl duration-100 overflow-x-hidden overflow-y-auto",
23
23
  className
24
24
  )}
25
25
  {...restProps}
@@ -12,6 +12,6 @@
12
12
  <DropdownMenuPrimitive.SubContent
13
13
  bind:ref
14
14
  data-slot="dropdown-menu-sub-content"
15
- class={cn("font-sans data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10000] bg-popover border-3 border-slate-400 text-slate-800 fill-slate-600 min-w-[96px] rounded-xl p-2 shadow-xl duration-100 w-auto", className)}
15
+ class={cn("font-sans data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10000] bg-popover border-3 border-border text-popover-foreground fill-popover-foreground min-w-[96px] rounded-xl p-2 shadow-xl duration-100 w-auto", className)}
16
16
  {...restProps}
17
17
  />
@@ -17,7 +17,7 @@
17
17
  data-slot="dropdown-menu-sub-trigger"
18
18
  data-inset={inset}
19
19
  class={cn(
20
- "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
20
+ "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-2.5 py-2 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
21
21
  className
22
22
  )}
23
23
  {...restProps}
@@ -31,6 +31,9 @@
31
31
  class: className,
32
32
  wrapperClass = "",
33
33
  active = false,
34
+ inactive = false,
35
+ dimmed = false,
36
+ localOnly = false,
34
37
  size = "icon-2xl",
35
38
  children,
36
39
  ...restProps
@@ -41,13 +44,21 @@
41
44
  );
42
45
  </script>
43
46
 
44
- <span data-trigger-button data-active={active || undefined} style:--sb-trigger-border-width={borderWidth}>
47
+ <span
48
+ data-trigger-button
49
+ data-active={active || undefined}
50
+ data-inactive={inactive || undefined}
51
+ data-dimmed={dimmed || undefined}
52
+ data-local-only={localOnly || undefined}
53
+ style:--sb-trigger-border-width={borderWidth}
54
+ >
45
55
  <Button
46
56
  variant="trigger"
47
57
  {size}
58
+ disabled={inactive}
48
59
  wrapperClass={cn(
49
60
  "smooth-corners [--smooth-corners:4] hover:rotate-2 focus-visible:rotate-2 transition-transform",
50
- active && "rotate-2",
61
+ active && !inactive && "rotate-2",
51
62
  wrapperClass
52
63
  )}
53
64
  class={cn(
@@ -62,7 +73,8 @@
62
73
 
63
74
  <style>
64
75
  [data-trigger-button] {
65
- display: contents;
76
+ display: inline-flex;
77
+ position: relative;
66
78
  }
67
79
  [data-trigger-button] :global([data-slot="button-wrapper"]) {
68
80
  --sc-border-color: var(--trigger-border, var(--color-slate-400));
@@ -82,4 +94,20 @@
82
94
  [data-trigger-button][data-active] :global([data-slot="button"]) {
83
95
  background-color: var(--trigger-bg-hover, var(--color-slate-300));
84
96
  }
97
+
98
+ /* Inactive: disabled-looking, no interaction */
99
+ [data-trigger-button][data-inactive] {
100
+ opacity: 0.45;
101
+ pointer-events: none;
102
+ }
103
+
104
+ /* Dimmed: reduced visibility, interactive on hover/focus */
105
+ [data-trigger-button][data-dimmed] {
106
+ opacity: 0.3;
107
+ transition: opacity 200ms;
108
+ }
109
+ [data-trigger-button][data-dimmed]:hover,
110
+ [data-trigger-button][data-dimmed]:focus-within {
111
+ opacity: 1;
112
+ }
85
113
  </style>
package/src/modes.css CHANGED
@@ -87,4 +87,12 @@ html.storyboard-mode-inspect {
87
87
  --trigger-bg-hover: color-mix(in srgb, var(--mode-color) 20%, white);
88
88
  --trigger-text: color-mix(in srgb, var(--mode-color) 85%, black);
89
89
  --trigger-border: color-mix(in srgb, var(--mode-color) 45%, white);
90
+ }
91
+
92
+ /* Dark trigger tokens — applied when toolbar follows a dark theme */
93
+ :root[data-sb-toolbar-theme^="dark"] {
94
+ --trigger-bg: #21262d;
95
+ --trigger-bg-hover: #30363d;
96
+ --trigger-text: #e6edf3;
97
+ --trigger-border: #30363d;
90
98
  }
@@ -17,6 +17,7 @@ import { initCommentsConfig, isCommentsEnabled } from './comments/config.js'
17
17
  import { initFeatureFlags } from './featureFlags.js'
18
18
  import { initPlugins } from './plugins.js'
19
19
  import { initUIConfig } from './uiConfig.js'
20
+ import { initToolbarConfig } from './toolbarConfigStore.js'
20
21
 
21
22
  let _mounted = false
22
23
 
@@ -85,12 +86,14 @@ async function injectUIStyles() {
85
86
  * @param {object} [options={}]
86
87
  * @param {string} [options.basePath='/'] - Base URL path (e.g. import.meta.env.BASE_URL)
87
88
  * @param {HTMLElement} [options.container=document.body] - Where to mount devtools
89
+ * @param {Record<string, () => Promise<any>>} [options.handlers={}] - Custom tool handlers (key → lazy loader)
88
90
  */
89
91
  export async function mountStoryboardCore(config = {}, options = {}) {
90
92
  if (_mounted) return
91
93
  _mounted = true
92
94
 
93
95
  const basePath = options.basePath || '/'
96
+ const customHandlers = options.handlers || {}
94
97
 
95
98
  // Apply saved theme to DOM immediately — before Svelte/React mount
96
99
  applyEarlyTheme()
@@ -130,9 +133,16 @@ export async function mountStoryboardCore(config = {}, options = {}) {
130
133
  ? deepMerge(defaultConfig, config.toolbar)
131
134
  : { ...defaultConfig }
132
135
 
133
- // Inject repository URL from storyboard.config.json into the command menu
136
+ // Inject repository URL from storyboard.config.json into the toolbar config
134
137
  if (config.repository?.owner && config.repository?.name) {
135
138
  const repoUrl = `https://github.com/${config.repository.owner}/${config.repository.name}`
139
+
140
+ // New tools schema
141
+ if (toolbarConfig.tools?.repository) {
142
+ toolbarConfig.tools.repository.url = repoUrl
143
+ }
144
+
145
+ // Legacy menus schema
136
146
  const commandMenu = toolbarConfig.menus?.command
137
147
  if (commandMenu?.actions) {
138
148
  const repoAction = commandMenu.actions.find(a => a.id === 'core/repository')
@@ -140,6 +150,9 @@ export async function mountStoryboardCore(config = {}, options = {}) {
140
150
  }
141
151
  }
142
152
 
153
+ // Seed the reactive toolbar config store (core → custom merge)
154
+ initToolbarConfig(toolbarConfig)
155
+
143
156
  // Skip all UI mounting when loaded inside a prototype embed iframe
144
157
  const isEmbed = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('_sb_embed')
145
158
  if (isEmbed) return
@@ -155,6 +168,7 @@ export async function mountStoryboardCore(config = {}, options = {}) {
155
168
  container: options.container,
156
169
  basePath,
157
170
  toolbarConfig,
171
+ customHandlers,
158
172
  })
159
173
 
160
174
  // Mount comments system if configured
package/src/sidepanel.css CHANGED
@@ -34,7 +34,7 @@ html:not(.sb-sidepanel-open) > body > #root {
34
34
 
35
35
  /* Push the CoreUIBar (fixed bottom-right) — side mode */
36
36
  html.sb-sidepanel-open:not(.sb-sidepanel-bottom) [data-core-ui-bar] {
37
- right: calc(var(--sidepanel-width) + 24px);
37
+ right: calc(var(--sidepanel-width) + 24px) !important;
38
38
  transition: right 0.25s ease;
39
39
  }
40
40
 
@@ -76,7 +76,7 @@ html.sb-sidepanel-open.sb-sidepanel-bottom.storyboard-mode-inspect > body > #roo
76
76
 
77
77
  /* Push the CoreUIBar up — bottom mode */
78
78
  html.sb-sidepanel-open.sb-sidepanel-bottom [data-core-ui-bar] {
79
- bottom: calc(var(--sidepanel-height) + 24px);
79
+ bottom: calc(var(--sidepanel-height) + 24px) !important;
80
80
  transition: bottom 0.25s ease;
81
81
  }
82
82
 
@@ -95,6 +95,10 @@ function _applyToDOM(theme: ThemeValue, resolved: string): void {
95
95
  // Internal attribute
96
96
  el.setAttribute('data-sb-theme', resolved)
97
97
 
98
+ // Toolbar theme — follows global theme when synced, stays light otherwise
99
+ const toolbarTheme = _syncTargets.toolbar ? resolved : 'light'
100
+ el.setAttribute('data-sb-toolbar-theme', toolbarTheme)
101
+
98
102
  // Primer CSS attributes — these drive @primer/react ThemeProvider and
99
103
  // Primer CSS custom-property layers without needing React state updates.
100
104
  if (theme === 'system') {
@@ -163,6 +167,68 @@ if (typeof window !== 'undefined') {
163
167
  })
164
168
  }
165
169
 
170
+ // ---------------------------------------------------------------------------
171
+ // Theme sync targets
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /** Which parts of the UI follow the global theme */
175
+ export interface ThemeSyncTargets {
176
+ prototype: boolean
177
+ toolbar: boolean
178
+ codeBoxes: boolean
179
+ }
180
+
181
+ const SYNC_STORAGE_KEY = 'sb-theme-sync'
182
+
183
+ const DEFAULT_SYNC: ThemeSyncTargets = {
184
+ prototype: true,
185
+ toolbar: false,
186
+ codeBoxes: true,
187
+ }
188
+
189
+ function readStoredSync(): ThemeSyncTargets {
190
+ if (typeof localStorage === 'undefined') return { ...DEFAULT_SYNC }
191
+ try {
192
+ const raw = localStorage.getItem(SYNC_STORAGE_KEY)
193
+ if (!raw) return { ...DEFAULT_SYNC }
194
+ return { ...DEFAULT_SYNC, ...JSON.parse(raw) }
195
+ } catch {
196
+ return { ...DEFAULT_SYNC }
197
+ }
198
+ }
199
+
200
+ let _syncTargets: ThemeSyncTargets = readStoredSync()
201
+ const _syncStore = writable<ThemeSyncTargets>(_syncTargets)
202
+
203
+ /**
204
+ * Get the current theme sync targets.
205
+ */
206
+ export function getThemeSyncTargets(): ThemeSyncTargets {
207
+ return { ..._syncTargets }
208
+ }
209
+
210
+ /**
211
+ * Set a theme sync target. Persists to localStorage.
212
+ */
213
+ export function setThemeSyncTarget(target: keyof ThemeSyncTargets, value: boolean): void {
214
+ _syncTargets = { ..._syncTargets, [target]: value }
215
+ _syncStore.set(_syncTargets)
216
+
217
+ if (typeof localStorage !== 'undefined') {
218
+ localStorage.setItem(SYNC_STORAGE_KEY, JSON.stringify(_syncTargets))
219
+ }
220
+
221
+ // Re-apply DOM attributes so toolbar/codebox can react
222
+ const state = snapshot(_current)
223
+ _applyToDOM(_current, state.resolved)
224
+ _dispatchEvent(_current, state.resolved)
225
+ }
226
+
227
+ /**
228
+ * Readable Svelte store for sync target state.
229
+ */
230
+ export const themeSyncState: Readable<ThemeSyncTargets> = { subscribe: _syncStore.subscribe }
231
+
166
232
  // ---------------------------------------------------------------------------
167
233
  // Boot — apply the stored theme immediately on import
168
234
  // ---------------------------------------------------------------------------
@@ -446,17 +446,22 @@
446
446
  {/snippet}
447
447
 
448
448
  {#snippet canvasEntry(canvas)}
449
- <a class="listItem" href={canvas.route}>
450
- <div class="cardBody">
451
- <p class="protoName">{canvas.name}</p>
452
- {#if canvas.description}
453
- <p class="protoDesc">{canvas.description}</p>
454
- {/if}
455
- {#if canvas.widgetCount > 0}
456
- <p class="flowDesc">{canvas.widgetCount} widget{canvas.widgetCount === 1 ? '' : 's'}</p>
457
- {/if}
458
- </div>
459
- </a>
449
+ <section class="protoGroup">
450
+ <a class="listItem" href={canvas.route}>
451
+ <div class="cardBody">
452
+ <p class="protoName">
453
+ <span class="protoIcon">{canvas.icon || '🎨'}</span>
454
+ {canvas.name}
455
+ </p>
456
+ {#if canvas.description}
457
+ <p class="protoDesc">{canvas.description}</p>
458
+ {/if}
459
+ {#if canvas.widgetCount > 0}
460
+ <p class="flowDesc">{canvas.widgetCount} widget{canvas.widgetCount === 1 ? '' : 's'}</p>
461
+ {/if}
462
+ </div>
463
+ </a>
464
+ </section>
460
465
  {/snippet}
461
466
 
462
467
  {#if viewMode === 'prototypes'}