@dfosco/storyboard-core 3.3.2 → 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 (50) hide show
  1. package/dist/storyboard-ui.css +9 -1
  2. package/dist/storyboard-ui.js +14659 -11413
  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 +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 +143 -32
  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 +36 -6
  50. package/toolbar.config.json +106 -48
@@ -1,43 +1,154 @@
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 configurable via toolbar.config.json's `highlighting` key:
9
+ * { "highlighting": { "dark": "github-dark-dimmed", "light": "github" } }
10
+ *
11
+ * Returns an adapter object matching the codeToHtml() call signature
12
+ * used by InspectorPanel.svelte.
12
13
  */
13
14
 
14
- // Variable indirection prevents any bundler from statically resolving
15
- const SHIKI = 'shiki'
15
+ import hljs from 'highlight.js/lib/core'
16
+ import javascript from 'highlight.js/lib/languages/javascript'
17
+ import typescript from 'highlight.js/lib/languages/typescript'
18
+ import xml from 'highlight.js/lib/languages/xml'
19
+ import { getToolbarConfig } from '../toolbarConfigStore.js'
16
20
 
17
- export async function createInspectorHighlighter() {
21
+ hljs.registerLanguage('javascript', javascript)
22
+ hljs.registerLanguage('typescript', typescript)
23
+ hljs.registerLanguage('xml', xml)
24
+ // Aliases for JSX/TSX — highlight.js uses xml for the JSX part,
25
+ // but we map these to typescript which handles JSX well enough
26
+ hljs.registerLanguage('jsx', javascript)
27
+ hljs.registerLanguage('tsx', typescript)
28
+
29
+ /** Map of highlight.js theme name → loaded CSS text (cached). */
30
+ const _loadedThemes = new Map()
31
+
32
+ /**
33
+ * Get the highlight.js theme name for the current context.
34
+ * Reads highlighting config (dark/light groups) from toolbar config.
35
+ * Uses theme sync targets to decide whether to follow global theme.
36
+ * When code boxes are not synced (default), always uses the dark theme.
37
+ */
38
+ function getThemeName() {
39
+ const config = getToolbarConfig()
40
+ const highlighting = config?.highlighting || {}
41
+ const darkTheme = highlighting.dark || 'github-dark-dimmed'
42
+ const lightTheme = highlighting.light || 'github'
43
+
44
+ // Check if code boxes should follow the global theme
45
+ let codeBoxesSynced = false
46
+ if (typeof localStorage !== 'undefined') {
47
+ try {
48
+ const raw = localStorage.getItem('sb-theme-sync')
49
+ if (raw) codeBoxesSynced = JSON.parse(raw).codeBoxes === true
50
+ } catch { /* ignore malformed localStorage */ }
51
+ }
52
+
53
+ // When not synced, always use dark theme for code boxes
54
+ if (!codeBoxesSynced) return darkTheme
55
+
56
+ // When synced, follow the current resolved theme
57
+ const sbTheme = typeof document !== 'undefined'
58
+ ? document.documentElement.getAttribute('data-sb-theme') || 'dark'
59
+ : 'dark'
60
+
61
+ return sbTheme.startsWith('dark') ? darkTheme : lightTheme
62
+ }
63
+
64
+ /**
65
+ * Ensure a highlight.js theme CSS is loaded into the document.
66
+ * Dynamically imports the CSS file and injects a <style> tag.
67
+ */
68
+ async function ensureThemeLoaded(themeName) {
69
+ if (_loadedThemes.has(themeName)) return
18
70
  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
71
+ // Dynamic import of CSS Vite handles this
72
+ await import(/* @vite-ignore */ `highlight.js/styles/${themeName}.css`)
73
+ _loadedThemes.set(themeName, true)
74
+ } catch {
75
+ // Fallback: try github-dark-dimmed if the requested theme doesn't exist
76
+ if (themeName !== 'github-dark-dimmed') {
77
+ try {
78
+ await import('highlight.js/styles/github-dark-dimmed.css')
79
+ _loadedThemes.set(themeName, true)
80
+ } catch { /* ignore */ }
33
81
  }
82
+ }
83
+ }
34
84
 
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
85
+ /**
86
+ * Escape HTML entities in a string.
87
+ */
88
+ function escapeHtml(str) {
89
+ return str
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;')
93
+ .replace(/"/g, '&quot;')
94
+ }
95
+
96
+ /**
97
+ * Create the inspector highlighter.
98
+ * Returns an object with codeToHtml() matching the Shiki API shape.
99
+ *
100
+ * @returns {Promise<{ codeToHtml: (code: string, options: { lang?: string, theme?: string, decorations?: Array }) => string }>}
101
+ */
102
+ export async function createInspectorHighlighter() {
103
+ const themeName = getThemeName()
104
+ await ensureThemeLoaded(themeName)
105
+
106
+ return {
107
+ /**
108
+ * Highlight code and return HTML string.
109
+ *
110
+ * @param {string} code - Source code to highlight
111
+ * @param {object} options
112
+ * @param {string} [options.lang] - Language identifier
113
+ * @param {string} [options.theme] - Ignored (theme comes from config)
114
+ * @param {Array<{ start: { line: number }, end: { line: number }, properties: { class: string } }>} [options.decorations]
115
+ * @returns {string} HTML string with highlighted code
116
+ */
117
+ codeToHtml(code, options = {}) {
118
+ const lang = options.lang || 'javascript'
119
+ const decorations = options.decorations || []
120
+
121
+ // Ensure current theme is loaded (non-blocking, already cached after first call)
122
+ const currentTheme = getThemeName()
123
+ ensureThemeLoaded(currentTheme)
124
+
125
+ // Highlight the code
126
+ let highlighted
127
+ try {
128
+ highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
129
+ } catch {
130
+ highlighted = escapeHtml(code)
131
+ }
132
+
133
+ // Split into lines and wrap each in a <span class="line">
134
+ const lines = highlighted.split('\n')
135
+ // Build a set of highlighted line numbers (0-indexed from decorations)
136
+ const highlightedLines = new Set()
137
+ for (const dec of decorations) {
138
+ if (dec.start && dec.properties?.class) {
139
+ for (let i = dec.start.line; i <= (dec.end?.line ?? dec.start.line); i++) {
140
+ highlightedLines.add(i)
141
+ }
142
+ }
143
+ }
144
+
145
+ const wrappedLines = lines.map((line, i) => {
146
+ const classes = ['line']
147
+ if (highlightedLines.has(i)) classes.push('highlighted-line')
148
+ return `<span class="${classes.join(' ')}">${line}</span>`
149
+ }).join('\n')
150
+
151
+ return `<pre class="hljs"><code>${wrappedLines}</code></pre>`
152
+ },
42
153
  }
43
154
  }
@@ -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
@@ -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: false,
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'}