@dfosco/storyboard-core 3.0.0 → 3.1.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.
@@ -16,6 +16,7 @@
16
16
  "trigger": "command",
17
17
  "icon": "iconoir/key-command",
18
18
  "meta": { "strokeWeight": 2 },
19
+ "default": true,
19
20
  "modes": ["*"],
20
21
  "actions": [
21
22
  { "type": "header", "label": "Command Menu" },
@@ -38,9 +39,10 @@
38
39
  { "type": "header", "label": "Create" },
39
40
  { "id": "workshop/create-prototype", "label": "New prototype", "type": "default", "modes": ["*"], "feature": "createPrototype" },
40
41
  { "id": "workshop/create-flow", "label": "New flow", "type": "default", "modes": ["*"], "feature": "createFlow" },
42
+ { "id": "workshop/create-canvas", "label": "New canvas", "type": "default", "modes": ["*"], "feature": "createCanvas" },
41
43
  { "type": "footer", "label": "Supported in local development" }
42
44
  ]
43
- },
45
+ },
44
46
  "flows": {
45
47
  "label": "Flows",
46
48
  "ariaLabel": "Switch flow",
@@ -64,10 +66,16 @@
64
66
  "inspector": {
65
67
  "ariaLabel": "Inspect components",
66
68
  "icon": "iconoir/square-dashed",
67
- "excludeRoutes": ["^/$", "/viewfinder"],
69
+ "excludeRoutes": ["^/$", "/viewfinder", "/canvas/"],
68
70
  "meta": { "strokeWeight": 2, "scale": 1.1 },
69
71
  "modes": ["*"],
70
72
  "sidepanel": "inspector"
71
73
  }
74
+ },
75
+
76
+ "canvasToolbar": {
77
+ "icon": "iconoir/grid-plus",
78
+ "meta": { "strokeWeight": 2, "scale": 1.2 },
79
+ "menuWidth": "220px"
72
80
  }
73
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,9 @@
19
19
  "exports": {
20
20
  ".": "./src/index.js",
21
21
  "./core-ui.config.json": "./core-ui.config.json",
22
+ "./canvas/materializer": "./src/canvas/materializer.js",
22
23
  "./vite/server": "./src/vite/server-plugin.js",
24
+ "./comments/svelte": "./src/comments/ui/index.js",
23
25
  "./comments": "./src/comments/index.js",
24
26
  "./comments/ui/comments.css": "./src/comments/ui/comment-layout.css",
25
27
  "./comments/ui/comment-layout.css": "./src/comments/ui/comment-layout.css",
@@ -0,0 +1,65 @@
1
+ <!--
2
+ CanvasCreateMenu — CoreUIBar dropdown for adding widgets to the active canvas.
3
+ Dispatches custom events to bridge Svelte → React.
4
+ Only visible when a canvas page is active.
5
+ -->
6
+
7
+ <script lang="ts">
8
+ import { TriggerButton } from '$lib/components/ui/trigger-button/index.js'
9
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'
10
+ import Icon from './svelte-plugin-ui/components/Icon.svelte'
11
+
12
+ interface Props {
13
+ config?: any
14
+ canvasName?: string
15
+ tabindex?: number
16
+ }
17
+
18
+ let { config = {}, canvasName = '', tabindex }: Props = $props()
19
+
20
+ const widgetTypes = [
21
+ { type: 'sticky-note', label: 'Sticky Note' },
22
+ { type: 'markdown', label: 'Markdown' },
23
+ { type: 'prototype', label: 'Prototype' },
24
+ ]
25
+
26
+ let menuOpen = $state(false)
27
+
28
+ function addWidget(type: string) {
29
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', {
30
+ detail: { type, canvasName }
31
+ }))
32
+ menuOpen = false
33
+ }
34
+ </script>
35
+
36
+ <DropdownMenu.Root bind:open={menuOpen}>
37
+ <DropdownMenu.Trigger>
38
+ {#snippet child({ props })}
39
+ <TriggerButton
40
+ active={menuOpen}
41
+ size="icon-xl"
42
+ aria-label={config.ariaLabel || 'Add widget'}
43
+ {tabindex}
44
+ {...props}
45
+ >
46
+ {#if config.icon}
47
+ <Icon name={config.icon} size={16} {...(config.meta || {})} />
48
+ {:else}
49
+ +
50
+ {/if}
51
+ </TriggerButton>
52
+ {/snippet}
53
+ </DropdownMenu.Trigger>
54
+
55
+ <DropdownMenu.Content side="top" align="start" sideOffset={16} class="min-w-[180px]" style={config.menuWidth ? `width: ${config.menuWidth}` : ''}>
56
+ <DropdownMenu.Label>Add to canvas</DropdownMenu.Label>
57
+ {#each widgetTypes as wt (wt.type)}
58
+ <DropdownMenu.Item onclick={() => addWidget(wt.type)}>
59
+ {wt.label}
60
+ </DropdownMenu.Item>
61
+ {/each}
62
+ <DropdownMenu.Separator />
63
+ <div class="px-2 py-1.5 text-xs text-muted-foreground">Supported in local development</div>
64
+ </DropdownMenu.Content>
65
+ </DropdownMenu.Root>
@@ -26,6 +26,8 @@
26
26
  let { basePath = '/' }: Props = $props()
27
27
 
28
28
  let visible = $state(true)
29
+ // Hide the entire toolbar when loaded inside a prototype embed iframe
30
+ const isEmbed = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('_sb_embed')
29
31
  let commandMenuOpen = $state(false)
30
32
  let ActionMenuButton: any = $state(null)
31
33
  let navVersion = $state(0)
@@ -38,6 +40,15 @@
38
40
  let commentsEnabled = $state(false)
39
41
  let SidePanel: any = $state(null)
40
42
  let toolbarEl: HTMLElement | null = $state(null)
43
+ let CanvasCreateMenu: any = $state(null)
44
+ let canvasActive = $state(false)
45
+ let activeCanvasName = $state('')
46
+ let canvasZoom = $state(100)
47
+ const canvasToolbarConfig = (coreUIConfig as any).canvasToolbar || {}
48
+
49
+ const ZOOM_STEP = 10
50
+ const ZOOM_MIN = 25
51
+ const ZOOM_MAX = 200
41
52
 
42
53
  // Roving tabindex: only one button in the toolbar is tabbable at a time
43
54
  let activeToolbarIndex = $state(-1)
@@ -150,7 +161,7 @@
150
161
  visible = !visible
151
162
  document.documentElement.classList.toggle('storyboard-chrome-hidden', !visible)
152
163
  }
153
- // Configurable shortcut to open the command menu
164
+ // Configurable shortcut to open the command menu (works even when hidden)
154
165
  if (openKey && e.key === openKey && (e.metaKey || e.ctrlKey)) {
155
166
  e.preventDefault()
156
167
  commandMenuOpen = !commandMenuOpen
@@ -375,6 +386,17 @@
375
386
  SidePanel = mod.default
376
387
  }
377
388
  } catch {}
389
+
390
+ // Load canvas create menu
391
+ try {
392
+ const mod = await import('./CanvasCreateMenu.svelte')
393
+ CanvasCreateMenu = mod.default
394
+ } catch {}
395
+
396
+ // Listen for canvas mount/unmount events (React↔Svelte bridge)
397
+ document.addEventListener('storyboard:canvas:mounted', handleCanvasMounted)
398
+ document.addEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
399
+ document.addEventListener('storyboard:canvas:zoom-changed', handleZoomChanged)
378
400
  })
379
401
 
380
402
  onDestroy(() => {
@@ -382,8 +404,42 @@
382
404
  if (bumpNav) window.removeEventListener('popstate', bumpNav)
383
405
  if (origPushState) history.pushState = origPushState
384
406
  if (origReplaceState) history.replaceState = origReplaceState
407
+ document.removeEventListener('storyboard:canvas:mounted', handleCanvasMounted)
408
+ document.removeEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
409
+ document.removeEventListener('storyboard:canvas:zoom-changed', handleZoomChanged)
385
410
  })
386
411
 
412
+ function handleCanvasMounted(e: Event) {
413
+ canvasActive = true
414
+ const detail = (e as CustomEvent).detail
415
+ activeCanvasName = detail?.name || ''
416
+ canvasZoom = detail?.zoom ?? 100
417
+ }
418
+
419
+ function handleCanvasUnmounted() {
420
+ canvasActive = false
421
+ activeCanvasName = ''
422
+ canvasZoom = 100
423
+ }
424
+
425
+ function handleZoomChanged(e: Event) {
426
+ canvasZoom = (e as CustomEvent).detail?.zoom ?? canvasZoom
427
+ }
428
+
429
+ function canvasZoomIn() {
430
+ const next = Math.min(ZOOM_MAX, canvasZoom + ZOOM_STEP)
431
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
432
+ }
433
+
434
+ function canvasZoomOut() {
435
+ const next = Math.max(ZOOM_MIN, canvasZoom - ZOOM_STEP)
436
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
437
+ }
438
+
439
+ function canvasZoomReset() {
440
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: 100 } }))
441
+ }
442
+
387
443
  // Flow info dialog state — driven by core/show-flow-info action
388
444
  let flowDialogOpen = $state(false)
389
445
  let flowName = $state('default')
@@ -398,48 +454,90 @@
398
454
  }
399
455
  </script>
400
456
 
401
- {#if visible}
457
+ {#if !isEmbed}
458
+ {#if visible && canvasActive && CanvasCreateMenu}
459
+ <div
460
+ class="fixed bottom-6 left-6 z-[9999] font-sans flex items-center gap-3"
461
+ role="toolbar"
462
+ aria-label="Canvas toolbar"
463
+ >
464
+ <Tooltip.Root>
465
+ <Tooltip.Trigger>
466
+ <CanvasCreateMenu config={canvasToolbarConfig} canvasName={activeCanvasName} tabindex={0} />
467
+ </Tooltip.Trigger>
468
+ <Tooltip.Content side="top">Add widget to canvas</Tooltip.Content>
469
+ </Tooltip.Root>
470
+
471
+ <div class="canvas-zoom-bar">
472
+ <button
473
+ class="canvas-zoom-btn"
474
+ onclick={canvasZoomOut}
475
+ disabled={canvasZoom <= ZOOM_MIN}
476
+ aria-label="Zoom out"
477
+ title="Zoom out"
478
+ >−</button>
479
+ <button
480
+ class="canvas-zoom-label"
481
+ onclick={canvasZoomReset}
482
+ aria-label="Reset zoom to 100%"
483
+ title="Reset to 100%"
484
+ >{canvasZoom}%</button>
485
+ <button
486
+ class="canvas-zoom-btn"
487
+ onclick={canvasZoomIn}
488
+ disabled={canvasZoom >= ZOOM_MAX}
489
+ aria-label="Zoom in"
490
+ title="Zoom in"
491
+ >+</button>
492
+ </div>
493
+ </div>
494
+ {/if}
402
495
  <div
403
496
  id="storyboard-controls"
404
497
  class="fixed bottom-6 right-6 z-[9999] font-sans flex items-end gap-3"
405
498
  data-core-ui-bar
406
499
  role="toolbar"
500
+ tabindex="0"
407
501
  aria-label="Storyboard controls"
408
502
  onkeydown={handleToolbarKeydown}
409
503
  bind:this={toolbarEl}
410
504
  >
411
- {#each visibleMenus as menu, i (menu.key)}
412
- <Tooltip.Root>
413
- <Tooltip.Trigger>
414
- {#if menu.sidepanel}
415
- <TriggerButton
416
- active={$sidePanelState.open && $sidePanelState.activeTab === menu.sidepanel}
417
- size="icon-xl"
418
- aria-label={menu.ariaLabel || menu.key}
419
- tabindex={getTabindex(i)}
420
- onfocus={() => { activeToolbarIndex = i }}
421
- onclick={() => togglePanel(menu.sidepanel)}
422
- >
423
- <Icon name={menu.icon || menu.key} size={16} {...(menu.meta || {})} />
424
- </TriggerButton>
425
- {:else if menu.action}
426
- <ActionMenuButton config={menu} tabindex={getTabindex(i)} />
427
- {:else if menu.key === 'create'}
428
- <CreateMenuButton features={createMenuFeatures} config={menu} tabindex={getTabindex(i)} />
429
- {:else if menu.key === 'comments'}
430
- <CommentsMenuButton config={menu} tabindex={getTabindex(i)} />
431
- {/if}
432
- </Tooltip.Trigger>
433
- <Tooltip.Content side="top">{menu.ariaLabel || menu.key}</Tooltip.Content>
434
- </Tooltip.Root>
435
- {/each}
505
+ {#if visible}
506
+ {#each visibleMenus as menu, i (menu.key)}
507
+ <Tooltip.Root>
508
+ <Tooltip.Trigger>
509
+ {#if menu.sidepanel}
510
+ <TriggerButton
511
+ active={$sidePanelState.open && $sidePanelState.activeTab === menu.sidepanel}
512
+ size="icon-xl"
513
+ aria-label={menu.ariaLabel || menu.key}
514
+ tabindex={getTabindex(i)}
515
+ onfocus={() => { activeToolbarIndex = i }}
516
+ onclick={() => togglePanel(menu.sidepanel)}
517
+ >
518
+ <Icon name={menu.icon || menu.key} size={16} {...(menu.meta || {})} />
519
+ </TriggerButton>
520
+ {:else if menu.action}
521
+ <ActionMenuButton config={menu} tabindex={getTabindex(i)} />
522
+ {:else if menu.key === 'create'}
523
+ <CreateMenuButton features={createMenuFeatures} config={menu} tabindex={getTabindex(i)} />
524
+ {:else if menu.key === 'comments'}
525
+ <CommentsMenuButton config={menu} tabindex={getTabindex(i)} />
526
+ {/if}
527
+ </Tooltip.Trigger>
528
+ <Tooltip.Content side="top">{menu.ariaLabel || menu.key}</Tooltip.Content>
529
+ </Tooltip.Root>
530
+ {/each}
531
+ {/if}
436
532
  {#if commandMenuConfig}
437
- <Tooltip.Root>
438
- <Tooltip.Trigger>
439
- <CommandMenu {basePath} bind:open={commandMenuOpen} bind:flowDialogOpen {flowName} {flowJson} {flowError} shortcuts={shortcutsConfig} tabindex={getTabindex(commandMenuIndex)} icon={commandMenuConfig.icon} iconMeta={commandMenuConfig.meta} />
440
- </Tooltip.Trigger>
441
- <Tooltip.Content side="top">Command Menu</Tooltip.Content>
442
- </Tooltip.Root>
533
+ <div class={visible || commandMenuOpen ? '' : 'default-button-dimmed'}>
534
+ <Tooltip.Root>
535
+ <Tooltip.Trigger>
536
+ <CommandMenu {basePath} bind:open={commandMenuOpen} bind:flowDialogOpen {flowName} {flowJson} {flowError} shortcuts={shortcutsConfig} tabindex={getTabindex(commandMenuIndex)} icon={commandMenuConfig.icon} iconMeta={commandMenuConfig.meta} />
537
+ </Tooltip.Trigger>
538
+ <Tooltip.Content side="top">Command Menu</Tooltip.Content>
539
+ </Tooltip.Root>
540
+ </div>
443
541
  {/if}
444
542
  </div>
445
543
  {/if}
@@ -448,3 +546,69 @@
448
546
  <SidePanel onClose={() => focusToolbarItem(activeToolbarIndex < 0 ? toolbarItemCount - 1 : activeToolbarIndex)} />
449
547
  {/if}
450
548
 
549
+ <style>
550
+ .canvas-zoom-bar {
551
+ display: flex;
552
+ align-items: center;
553
+ border-radius: 10px;
554
+ border: 1.5px solid var(--trigger-border, var(--color-slate-400));
555
+ background: var(--trigger-bg, var(--color-slate-100));
556
+ overflow: hidden;
557
+ }
558
+
559
+ .canvas-zoom-btn {
560
+ all: unset;
561
+ cursor: pointer;
562
+ display: flex;
563
+ align-items: center;
564
+ justify-content: center;
565
+ width: 36px;
566
+ height: 32px;
567
+ font-size: 16px;
568
+ font-weight: 600;
569
+ color: var(--trigger-text, var(--color-slate-600));
570
+ transition: background 120ms;
571
+ }
572
+
573
+ .canvas-zoom-btn:hover:not(:disabled) {
574
+ background: var(--trigger-bg-hover, var(--color-slate-300));
575
+ }
576
+
577
+ .canvas-zoom-btn:disabled {
578
+ opacity: 0.3;
579
+ cursor: default;
580
+ }
581
+
582
+ .canvas-zoom-label {
583
+ all: unset;
584
+ cursor: pointer;
585
+ display: flex;
586
+ align-items: center;
587
+ justify-content: center;
588
+ min-width: 48px;
589
+ height: 32px;
590
+ padding: 0 4px;
591
+ font-size: 11px;
592
+ font-weight: 600;
593
+ font-variant-numeric: tabular-nums;
594
+ color: var(--trigger-text, var(--color-slate-600));
595
+ border-left: 1.5px solid var(--trigger-border, var(--color-slate-400));
596
+ border-right: 1.5px solid var(--trigger-border, var(--color-slate-400));
597
+ transition: background 120ms;
598
+ }
599
+
600
+ .canvas-zoom-label:hover {
601
+ background: var(--trigger-bg-hover, var(--color-slate-300));
602
+ }
603
+
604
+ .default-button-dimmed {
605
+ opacity: 0.3;
606
+ transition: opacity 200ms;
607
+ }
608
+
609
+ .default-button-dimmed:hover,
610
+ .default-button-dimmed:focus-within {
611
+ opacity: 1;
612
+ }
613
+ </style>
614
+
@@ -34,6 +34,37 @@
34
34
  /** @type {{ owner: string, name: string } | null} */
35
35
  let repoInfo = $state(null)
36
36
 
37
+ /** @type {{ files: string[], sources: Record<string, string>, repo: { owner: string, name: string } | null } | null} */
38
+ let staticInspectorData = null
39
+
40
+ /**
41
+ * Load the build-time static inspector JSON (production only).
42
+ * Cached after the first successful fetch.
43
+ */
44
+ async function loadStaticData() {
45
+ if (staticInspectorData) return staticInspectorData
46
+ try {
47
+ const res = await fetch(`${import.meta.env.BASE_URL}_storyboard/inspector.json`)
48
+ if (res.ok) {
49
+ staticInspectorData = await res.json()
50
+ return staticInspectorData
51
+ }
52
+ } catch {}
53
+ return null
54
+ }
55
+
56
+ /**
57
+ * Fetch source file content — uses dev middleware in dev, static JSON in prod.
58
+ */
59
+ async function fetchSourceContent(filePath) {
60
+ if (import.meta.env.DEV) {
61
+ const res = await fetch(`/_storyboard/docs/source?path=${encodeURIComponent(filePath)}`)
62
+ return res.ok ? (await res.json())?.content || '' : ''
63
+ }
64
+ const data = await loadStaticData()
65
+ return data?.sources?.[filePath] || ''
66
+ }
67
+
37
68
  let mouseMode = null
38
69
 
39
70
  const hasSelection = $derived(componentInfo !== null)
@@ -155,11 +186,8 @@
155
186
 
156
187
  async function getHighlighter() {
157
188
  if (highlighter) return highlighter
158
- const { createHighlighter } = await import('shiki')
159
- highlighter = await createHighlighter({
160
- themes: ['github-dark'],
161
- langs: ['jsx', 'tsx', 'javascript', 'typescript'],
162
- })
189
+ const { createInspectorHighlighter } = await import('./inspector/highlighter.js')
190
+ highlighter = await createInspectorHighlighter()
163
191
  return highlighter
164
192
  }
165
193
 
@@ -234,10 +262,9 @@
234
262
  sourceLoading = true
235
263
  sourcePath = path
236
264
  highlightedHtml = ''
237
- fetch(`/_storyboard/docs/source?path=${encodeURIComponent(path)}`)
238
- .then(r => r.ok ? r.json() : null)
239
- .then(async (data) => {
240
- sourceCode = data?.content || ''
265
+ fetchSourceContent(path)
266
+ .then(async (content) => {
267
+ sourceCode = content
241
268
  // Try the selected component first, then walk up the chain
242
269
  matchedLine = findComponentLine(sourceCode, componentInfo)
243
270
  if (matchedLine < 0 && componentChain.length > 0) {
@@ -336,20 +363,30 @@
336
363
  // Auto-start inspector mode
337
364
  startInspecting()
338
365
 
339
- // Pre-fetch file list and repo info in parallel
340
- try {
341
- const [filesRes, repoRes] = await Promise.all([
342
- fetch('/_storyboard/docs/files'),
343
- fetch('/_storyboard/docs/repo'),
344
- ])
345
- if (filesRes.ok) {
346
- const data = await filesRes.json()
366
+ // Pre-fetch file list and repo info
367
+ if (import.meta.env.DEV) {
368
+ // Dev: use live middleware endpoints
369
+ try {
370
+ const [filesRes, repoRes] = await Promise.all([
371
+ fetch('/_storyboard/docs/files'),
372
+ fetch('/_storyboard/docs/repo'),
373
+ ])
374
+ if (filesRes.ok) {
375
+ const data = await filesRes.json()
376
+ knownFiles = data.files || []
377
+ }
378
+ if (repoRes.ok) {
379
+ repoInfo = await repoRes.json()
380
+ }
381
+ } catch {}
382
+ } else {
383
+ // Production: load from static build-time JSON
384
+ const data = await loadStaticData()
385
+ if (data) {
347
386
  knownFiles = data.files || []
387
+ repoInfo = data.repo || null
348
388
  }
349
- if (repoRes.ok) {
350
- repoInfo = await repoRes.json()
351
- }
352
- } catch {}
389
+ }
353
390
  })
354
391
 
355
392
  onDestroy(() => {