@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21

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 (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # pre-push pipeline — runs checks and generators before pushing.
4
+ #
5
+ # Pipeline stages run in order. Each stage can:
6
+ # - Pass silently (exit 0)
7
+ # - Fail and block the push (exit 1) — all changes are reverted
8
+ # - Generate files that get auto-committed
9
+ #
10
+ # Add new stages by defining a function and adding it to STAGES.
11
+ #
12
+ # Installed by: storyboard setup / storyboard-scaffold
13
+ #
14
+
15
+ set -euo pipefail
16
+
17
+ # ── Configuration ─────────────────────────────────────────────────
18
+ # Ordered list of stages. Add new stages here.
19
+ STAGES=(
20
+ stage_snapshots
21
+ )
22
+
23
+ # ── Helpers ───────────────────────────────────────────────────────
24
+
25
+ SNAPSHOT_SHA=""
26
+ RED='\033[0;31m'
27
+ GREEN='\033[0;32m'
28
+ DIM='\033[2m'
29
+ BOLD='\033[1m'
30
+ RESET='\033[0m'
31
+
32
+ log() { echo -e "${DIM}[pre-push]${RESET} $*"; }
33
+ ok() { echo -e "${DIM}[pre-push]${RESET} ${GREEN}✓${RESET} $*"; }
34
+ fail() { echo -e "${DIM}[pre-push]${RESET} ${RED}✗${RESET} $*"; }
35
+
36
+ save_snapshot() {
37
+ SNAPSHOT_SHA=$(git rev-parse HEAD)
38
+ }
39
+
40
+ revert_snapshot() {
41
+ if [ -n "$SNAPSHOT_SHA" ] && [ "$(git rev-parse HEAD)" != "$SNAPSHOT_SHA" ]; then
42
+ log "Reverting auto-committed changes..."
43
+ git reset --soft "$SNAPSHOT_SHA" 2>/dev/null || true
44
+ fi
45
+ }
46
+
47
+ auto_commit() {
48
+ local label="$1"
49
+ shift
50
+ local paths=("$@")
51
+
52
+ local changes=""
53
+ for p in "${paths[@]}"; do
54
+ changes+=$(git status --porcelain "$p" 2>/dev/null || true)
55
+ done
56
+
57
+ if [ -n "$changes" ]; then
58
+ git add "${paths[@]}" 2>/dev/null
59
+ git commit -m "chore: ${label}" --no-verify --allow-empty 2>/dev/null
60
+ ok "${label} (committed)"
61
+ return 0
62
+ fi
63
+ return 1
64
+ }
65
+
66
+ # ── Stages ────────────────────────────────────────────────────────
67
+
68
+ stage_test() {
69
+ if [ ! -f "vitest.config.js" ] && [ ! -f "vitest.config.ts" ]; then return 0; fi
70
+ if ! command -v npx &>/dev/null; then return 0; fi
71
+
72
+ log "Running tests..."
73
+ if npx --no-install vitest run --reporter=dot 2>&1; then
74
+ ok "Tests passed"
75
+ else
76
+ fail "Tests failed — push blocked"
77
+ return 1
78
+ fi
79
+ }
80
+
81
+ stage_snapshots() {
82
+ local canvas_files
83
+ canvas_files=$(find src/canvas -name "*.canvas.jsonl" 2>/dev/null | head -1)
84
+ [ -z "$canvas_files" ] && return 0
85
+
86
+ if ! npx --no-install storyboard snapshots 2>&1; then
87
+ log "Snapshot generation skipped"
88
+ return 0
89
+ fi
90
+
91
+ auto_commit "update canvas snapshots" \
92
+ "assets/canvas/snapshots/" \
93
+ "assets/canvas/images/" \
94
+ "src/canvas/" \
95
+ || true
96
+ }
97
+
98
+ # ── Pipeline runner ───────────────────────────────────────────────
99
+
100
+ main() {
101
+ save_snapshot
102
+
103
+ for stage in "${STAGES[@]}"; do
104
+ if ! "$stage"; then
105
+ revert_snapshot
106
+ fail "${BOLD}Push aborted${RESET} by ${stage}"
107
+ exit 1
108
+ fi
109
+ done
110
+
111
+ log "${GREEN}All stages passed${RESET}"
112
+ }
113
+
114
+ main
@@ -30,6 +30,17 @@
30
30
  "target": ".github/skills/",
31
31
  "mode": "updateable",
32
32
  "directory": true
33
+ },
34
+ {
35
+ "source": "scaffold/deploy.yml",
36
+ "target": ".github/workflows/deploy.yml",
37
+ "mode": "scaffold"
38
+ },
39
+ {
40
+ "source": "scaffold/githooks/",
41
+ "target": ".githooks/",
42
+ "mode": "updateable",
43
+ "directory": true
33
44
  }
34
45
  ]
35
46
  }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "customDomain": "",
2
3
  "repository": {
3
4
  "owner": "",
4
5
  "name": ""
@@ -22,5 +23,7 @@
22
23
  "createCanvas": true
23
24
  }
24
25
  },
25
- "featureFlags": {}
26
+ "featureFlags": {},
27
+ "canvas": {},
28
+ "commandPalette": {}
26
29
  }
@@ -81,7 +81,12 @@
81
81
  {#if child.type === 'radio'}
82
82
  <DropdownMenu.RadioItem
83
83
  value={child.id}
84
- onclick={() => { if (child.execute) child.execute(); menuOpen = false }}
84
+ onclick={(e) => {
85
+ if (child.href && (e.metaKey || e.ctrlKey)) {
86
+ e.preventDefault(); window.open(child.href, '_blank'); menuOpen = false; return
87
+ }
88
+ if (child.execute) child.execute(); menuOpen = false
89
+ }}
85
90
  >
86
91
  {child.label}
87
92
  </DropdownMenu.RadioItem>
@@ -98,7 +103,12 @@
98
103
  {child.label}
99
104
  </DropdownMenu.CheckboxItem>
100
105
  {:else}
101
- <DropdownMenu.Item onclick={() => { if (child.execute) child.execute(); menuOpen = false }}>
106
+ <DropdownMenu.Item onclick={(e) => {
107
+ if (child.href && (e.metaKey || e.ctrlKey)) {
108
+ e.preventDefault(); window.open(child.href, '_blank'); menuOpen = false; return
109
+ }
110
+ if (child.execute) child.execute(); menuOpen = false
111
+ }}>
102
112
  {child.label}
103
113
  </DropdownMenu.Item>
104
114
  {/if}
@@ -7,6 +7,9 @@
7
7
  <script lang="ts">
8
8
  import { TriggerButton } from './lib/components/ui/trigger-button/index.js'
9
9
  import * as DropdownMenu from './lib/components/ui/dropdown-menu/index.js'
10
+ import { Button } from './lib/components/ui/button/index.js'
11
+ import { Input } from './lib/components/ui/input/index.js'
12
+ import { Label } from './lib/components/ui/label/index.js'
10
13
  import Icon from './svelte-plugin-ui/components/Icon.svelte'
11
14
 
12
15
  interface Props {
@@ -25,7 +28,73 @@
25
28
  { type: 'prototype', label: 'Prototype embed' },
26
29
  ]
27
30
 
31
+ interface StoryEntry {
32
+ name: string
33
+ path: string
34
+ exports: string[]
35
+ }
36
+
37
+ type View = 'menu' | 'create' | 'notification'
38
+
28
39
  let menuOpen = $state(false)
40
+ let view: View = $state('menu')
41
+ let stories: StoryEntry[] = $state([])
42
+ let storiesLoaded = $state(false)
43
+
44
+ // Create form state
45
+ let createName = $state('')
46
+ let createLocation = $state('canvas')
47
+ let createFormat = $state('jsx')
48
+ let submitting = $state(false)
49
+ let createError: string | null = $state(null)
50
+ let notificationPath: string | null = $state(null)
51
+
52
+ const kebabName = $derived(
53
+ createName.replace(/[^a-zA-Z0-9\s_-]/g, '').trim().replace(/[\s_]+/g, '-').toLowerCase().replace(/-+/g, '-').replace(/^-|-$/g, '')
54
+ )
55
+ const nameError = $derived(
56
+ createName.trim() && !kebabName ? 'Name must contain at least one alphanumeric character' : null
57
+ )
58
+ const filePreview = $derived(
59
+ kebabName ? `${kebabName}.story.${createFormat}` : ''
60
+ )
61
+ const canSubmit = $derived(!!kebabName && !nameError && !submitting)
62
+
63
+ function getApiUrl() {
64
+ const basePath = (window as any).__STORYBOARD_BASE_PATH__ || '/'
65
+ return basePath.replace(/\/$/, '') + '/_storyboard/canvas'
66
+ }
67
+
68
+ async function loadStories() {
69
+ try {
70
+ const res = await fetch(getApiUrl() + '/stories')
71
+ if (res.ok) {
72
+ const data = await res.json()
73
+ stories = data.stories || []
74
+ }
75
+ } catch { /* ignore */ }
76
+ storiesLoaded = true
77
+ }
78
+
79
+ // Load stories when menu opens
80
+ $effect(() => {
81
+ if (menuOpen) loadStories()
82
+ })
83
+
84
+ // Reset view when menu closes (but not during view transitions)
85
+ $effect(() => {
86
+ if (!menuOpen && view === 'menu') {
87
+ resetCreateForm()
88
+ }
89
+ })
90
+
91
+ function resetCreateForm() {
92
+ createName = ''
93
+ createLocation = 'canvas'
94
+ createFormat = 'jsx'
95
+ createError = null
96
+ submitting = false
97
+ }
29
98
 
30
99
  function addWidget(type: string) {
31
100
  document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', {
@@ -33,9 +102,70 @@
33
102
  }))
34
103
  menuOpen = false
35
104
  }
105
+
106
+ function addStoryWidget(storyId: string) {
107
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:add-story-widget', {
108
+ detail: { storyId, canvasName }
109
+ }))
110
+ menuOpen = false
111
+ }
112
+
113
+ function showCreateForm() {
114
+ resetCreateForm()
115
+ view = 'create'
116
+ }
117
+
118
+ async function submitCreate() {
119
+ if (!canSubmit) return
120
+ submitting = true; createError = null
121
+ try {
122
+ const bridgeState = (window as any).__storyboardCanvasBridgeState
123
+ const activeCanvasName = bridgeState?.name || canvasName
124
+
125
+ const res = await fetch(getApiUrl() + '/create-story', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({
129
+ name: kebabName,
130
+ location: createLocation,
131
+ format: createFormat,
132
+ canvasName: createLocation === 'canvas' ? activeCanvasName : undefined,
133
+ }),
134
+ })
135
+ const data = await res.json()
136
+ if (!res.ok) { createError = data.error || 'Failed to create component'; submitting = false; return }
137
+
138
+ // Add the new component to the canvas
139
+ addStoryWidget(data.name)
140
+
141
+ // Show inline notification
142
+ notificationPath = data.path
143
+ view = 'notification'
144
+ menuOpen = true
145
+
146
+ // Refresh story list for next time
147
+ storiesLoaded = false
148
+
149
+ // Auto-dismiss after 6 seconds
150
+ setTimeout(() => {
151
+ if (view === 'notification') {
152
+ menuOpen = false
153
+ view = 'menu'
154
+ notificationPath = null
155
+ }
156
+ }, 6000)
157
+ } catch (err: any) { createError = err.message || 'Network error' } finally { submitting = false }
158
+ }
36
159
  </script>
37
160
 
38
- <DropdownMenu.Root bind:open={menuOpen}>
161
+ <DropdownMenu.Root bind:open={menuOpen} onOpenChange={(open) => {
162
+ if (!open && view !== 'menu') {
163
+ // User dismissed the panel — reset everything
164
+ view = 'menu'
165
+ notificationPath = null
166
+ resetCreateForm()
167
+ }
168
+ }}>
39
169
  <DropdownMenu.Trigger>
40
170
  {#snippet child({ props })}
41
171
  <TriggerButton
@@ -54,14 +184,102 @@
54
184
  {/snippet}
55
185
  </DropdownMenu.Trigger>
56
186
 
57
- <DropdownMenu.Content side="top" align="start" sideOffset={16} class="min-w-[180px]" style={config.menuWidth ? `width: ${config.menuWidth}` : ''}>
58
- <DropdownMenu.Label>Add to canvas</DropdownMenu.Label>
59
- {#each widgetTypes as wt (wt.type)}
60
- <DropdownMenu.Item onclick={() => addWidget(wt.type)}>
61
- {wt.label}
62
- </DropdownMenu.Item>
63
- {/each}
64
- <DropdownMenu.Separator />
65
- <div class="px-2 py-1.5 text-xs text-muted-foreground flex flex-row items-baseline"><span class="inline-flex w-2 h-2 rounded-full mr-1.5" style="background: hsl(212, 92%, 45%)"></span>Only available in dev environment</div>
187
+ <DropdownMenu.Content side="top" align="start" sideOffset={16} class="min-w-[180px]" style={config.menuWidth ? `width: ${config.menuWidth}` : ''} onInteractOutside={(e) => { if (view === 'create') e.preventDefault() }}>
188
+ {#if view === 'menu'}
189
+ <DropdownMenu.Label>Add to canvas</DropdownMenu.Label>
190
+ {#each widgetTypes as wt (wt.type)}
191
+ <DropdownMenu.Item onclick={() => addWidget(wt.type)}>
192
+ {wt.label}
193
+ </DropdownMenu.Item>
194
+ {/each}
195
+
196
+ <DropdownMenu.Sub>
197
+ <DropdownMenu.SubTrigger>Component</DropdownMenu.SubTrigger>
198
+ <DropdownMenu.SubContent class="min-w-[200px] max-h-[320px] overflow-y-auto">
199
+ <button
200
+ class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground w-full text-left bg-transparent border-none"
201
+ onclick={(e) => { e.preventDefault(); e.stopPropagation(); showCreateForm() }}
202
+ onpointerdown={(e) => e.stopPropagation()}
203
+ >
204
+ <span class="font-medium">Create new component…</span>
205
+ </button>
206
+ {#if stories.length > 0}
207
+ <DropdownMenu.Separator />
208
+ <DropdownMenu.Label>Existing stories</DropdownMenu.Label>
209
+ {#each stories as story (story.name)}
210
+ <DropdownMenu.Item onclick={() => addStoryWidget(story.name)}>
211
+ <span class="flex flex-col">
212
+ <span>{story.name}</span>
213
+ <span class="text-xs text-muted-foreground">{story.path}</span>
214
+ </span>
215
+ </DropdownMenu.Item>
216
+ {/each}
217
+ {/if}
218
+ </DropdownMenu.SubContent>
219
+ </DropdownMenu.Sub>
220
+
221
+ <DropdownMenu.Separator />
222
+ <div class="px-2 py-1.5 text-xs text-muted-foreground flex flex-row items-baseline"><span class="inline-flex w-2 h-2 rounded-full mr-1.5" style="background: hsl(212, 92%, 45%)"></span>Only available in dev environment</div>
223
+
224
+ {:else if view === 'create'}
225
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
226
+ <div class="p-3 space-y-3 min-w-[280px]" onkeydown={(e) => { if (e.key === 'Enter' && canSubmit) submitCreate(); if (e.key === 'Escape') { view = 'menu' } }} onclick={(e) => e.stopPropagation()} onpointerdown={(e) => e.stopPropagation()}>
227
+ <div class="flex items-center justify-between">
228
+ <span class="text-sm font-medium">Create component</span>
229
+ <button class="text-muted-foreground hover:text-foreground text-xs bg-transparent border-none cursor-pointer p-0.5" onclick={() => { view = 'menu' }}>← Back</button>
230
+ </div>
231
+
232
+ <div class="space-y-1">
233
+ <Label for="sb-create-comp-name" class="text-xs">Name</Label>
234
+ <Input id="sb-create-comp-name" placeholder="e.g. user-card" autocomplete="off" spellcheck="false" bind:value={createName} class="h-8 text-sm" />
235
+ {#if nameError}<p class="text-xs text-destructive">{nameError}</p>{/if}
236
+ {#if filePreview}<p class="text-xs text-muted-foreground">{filePreview}</p>{/if}
237
+ </div>
238
+
239
+ <fieldset class="space-y-1">
240
+ <Label class="text-xs">Location</Label>
241
+ <div class="flex flex-col gap-1">
242
+ <label class="flex items-center gap-1.5 text-xs cursor-pointer">
243
+ <input type="radio" name="sb-create-location" value="canvas" bind:group={createLocation} class="accent-primary" />
244
+ This canvas directory
245
+ </label>
246
+ <label class="flex items-center gap-1.5 text-xs cursor-pointer">
247
+ <input type="radio" name="sb-create-location" value="components" bind:group={createLocation} class="accent-primary" />
248
+ <code class="text-xs">src/components/</code>
249
+ </label>
250
+ </div>
251
+ </fieldset>
252
+
253
+ <fieldset class="space-y-1">
254
+ <Label class="text-xs">Format</Label>
255
+ <div class="flex gap-3">
256
+ <label class="flex items-center gap-1.5 text-xs cursor-pointer">
257
+ <input type="radio" name="sb-create-format" value="jsx" bind:group={createFormat} class="accent-primary" />
258
+ JSX
259
+ </label>
260
+ <label class="flex items-center gap-1.5 text-xs cursor-pointer">
261
+ <input type="radio" name="sb-create-format" value="tsx" bind:group={createFormat} class="accent-primary" />
262
+ TSX
263
+ </label>
264
+ </div>
265
+ </fieldset>
266
+
267
+ {#if createError}<p class="text-xs text-destructive">{createError}</p>{/if}
268
+
269
+ <div class="flex gap-2 justify-end pt-1">
270
+ <Button variant="outline" size="sm" onclick={() => { view = 'menu' }}>Cancel</Button>
271
+ <Button size="sm" onclick={submitCreate} disabled={!canSubmit}>{submitting ? 'Creating…' : 'Create'}</Button>
272
+ </div>
273
+ </div>
274
+
275
+ {:else if view === 'notification'}
276
+ <div class="p-3 min-w-[260px] space-y-1">
277
+ <p class="text-sm font-medium">✓ Component added to canvas</p>
278
+ {#if notificationPath}
279
+ <p class="text-xs text-muted-foreground">To edit your component, go to</p>
280
+ <code class="text-xs block bg-muted px-2 py-1 rounded">{notificationPath}</code>
281
+ {/if}
282
+ </div>
283
+ {/if}
66
284
  </DropdownMenu.Content>
67
285
  </DropdownMenu.Root>
@@ -29,6 +29,8 @@
29
29
  detail: { gridSize }
30
30
  }))
31
31
  }
32
+ // Request current snap state from React (may have dispatched before we mounted)
33
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state-request'))
32
34
  })
33
35
 
34
36
  onDestroy(() => {
@@ -13,12 +13,14 @@
13
13
  import { onMount, onDestroy, untrack } from 'svelte'
14
14
  import './core-ui-colors.css'
15
15
  import CommandMenu from './CommandMenu.svelte'
16
+ import PwaInstallBanner from './PwaInstallBanner.svelte'
16
17
  import { TriggerButton } from './lib/components/ui/trigger-button/index.js'
17
18
  import * as Tooltip from './lib/components/ui/tooltip/index.js'
18
19
  import Icon from './svelte-plugin-ui/components/Icon.svelte'
19
20
  import { modeState } from './svelte-plugin-ui/stores/modeStore.js'
20
21
  import { sidePanelState, togglePanel } from './stores/sidePanelStore.js'
21
- import { initCommandActions, registerCommandAction, getActionChildren, hasChildrenProvider, isExcludedByRoute, setRoutingBasePath } from './commandActions.js'
22
+ import { initCommandActions, registerCommandAction, getActionChildren, hasChildrenProvider, isExcludedByRoute, setRoutingBasePath, setDynamicActions, clearDynamicActions } from './commandActions.js'
23
+ import { isMobile, subscribeToMobile } from './mobileViewport.js'
22
24
  import { isMenuHidden } from './uiConfig.js'
23
25
  import { subscribeToToolbarConfig, getToolbarConfig } from './toolbarConfigStore.js'
24
26
  import { initToolbarToolStates, getToolbarToolState, isToolbarToolLocalOnly, subscribeToToolbarToolStates } from './toolStateStore.js'
@@ -74,6 +76,11 @@
74
76
  let canvasZoom = $state(100)
75
77
  let toolStateVersion = $state(0)
76
78
 
79
+ // Mobile viewport state — on narrow screens, toolbar tools move into the command menu
80
+ let isMobileState = $state(isMobile())
81
+ let unsubMobile: (() => void) | null = null
82
+ let mobileActionsRegistered = false
83
+
77
84
  // Roving tabindex: only one button in the toolbar is tabbable at a time
78
85
  let activeToolbarIndex = $state(-1)
79
86
 
@@ -361,7 +368,7 @@
361
368
  }
362
369
  }
363
370
 
364
- bumpNav = () => { navVersion++; syncPrototypeToolbar() }
371
+ bumpNav = () => { navVersion++; syncPrototypeToolbar(); syncMobileActions() }
365
372
  window.addEventListener('popstate', bumpNav)
366
373
  origPushState = history.pushState.bind(history)
367
374
  history.pushState = (...args: any[]) => { origPushState(...args); bumpNav() }
@@ -451,6 +458,13 @@
451
458
  document.addEventListener('storyboard:canvas:zoom-changed', handleZoomChanged)
452
459
  document.addEventListener('storyboard:canvas:status', handleCanvasMounted)
453
460
  syncCanvasBridgeState()
461
+
462
+ // Subscribe to mobile viewport changes and sync mobile command actions
463
+ syncMobileActions()
464
+ unsubMobile = subscribeToMobile((mobile: boolean) => {
465
+ isMobileState = mobile
466
+ syncMobileActions()
467
+ })
454
468
  })
455
469
 
456
470
  onDestroy(() => {
@@ -458,6 +472,8 @@
458
472
  if (bumpNav) window.removeEventListener('popstate', bumpNav)
459
473
  if (origPushState) history.pushState = origPushState
460
474
  if (origReplaceState) history.replaceState = origReplaceState
475
+ if (unsubMobile) unsubMobile()
476
+ clearDynamicActions('mobile-toolbar')
461
477
  document.removeEventListener('storyboard:canvas:mounted', handleCanvasMounted)
462
478
  document.removeEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
463
479
  document.removeEventListener('storyboard:canvas:zoom-changed', handleZoomChanged)
@@ -469,12 +485,14 @@
469
485
  const detail = (e as CustomEvent).detail
470
486
  activeCanvasName = detail?.name || ''
471
487
  canvasZoom = detail?.zoom ?? 100
488
+ syncMobileActions()
472
489
  }
473
490
 
474
491
  function handleCanvasUnmounted() {
475
492
  canvasActive = false
476
493
  activeCanvasName = ''
477
494
  canvasZoom = 100
495
+ syncMobileActions()
478
496
  }
479
497
 
480
498
  function handleZoomChanged(e: Event) {
@@ -495,6 +513,132 @@
495
513
  }
496
514
  }
497
515
 
516
+ /**
517
+ * Sync mobile command actions — when in mobile viewport, toolbar tools
518
+ * become dynamic command actions in the ⌘ menu.
519
+ */
520
+ async function syncMobileActions() {
521
+ if (!isMobileState) {
522
+ if (mobileActionsRegistered) {
523
+ clearDynamicActions('mobile-toolbar')
524
+ mobileActionsRegistered = false
525
+ }
526
+ return
527
+ }
528
+
529
+ const actions: any[] = []
530
+ const handlers: Record<string, any> = {}
531
+ const toolConfigs = config.tools || {}
532
+
533
+ // Main-toolbar tools → command actions
534
+ actions.push({ type: 'header', label: 'Tools', id: '_mobile_header' })
535
+
536
+ for (const [key, tool] of Object.entries(toolConfigs as Record<string, any>)) {
537
+ if (tool.surface !== 'main-toolbar') continue
538
+ if (tool.render === 'separator') continue
539
+ if (!tool.prod && !isLocalDev) continue
540
+ if (getToolbarToolState(key) === 'disabled') continue
541
+ if (isExcludedByRoute(tool)) continue
542
+
543
+ // Always use mobile:-prefixed ids to avoid clobbering shared desktop handlers
544
+ const mobileId = `mobile:${key}`
545
+ const desktopActionId = tool.handler || `core:${key}`
546
+
547
+ // Menu tools with getChildren → submenu (delegate to existing desktop handler)
548
+ if (tool.render === 'menu' && hasChildrenProvider(desktopActionId)) {
549
+ actions.push({
550
+ id: mobileId,
551
+ label: tool.label || tool.ariaLabel,
552
+ type: 'submenu',
553
+ modes: tool.modes || ['*'],
554
+ excludeRoutes: tool.excludeRoutes,
555
+ toolKey: key,
556
+ localOnly: !tool.prod,
557
+ })
558
+ handlers[mobileId] = {
559
+ getChildren: () => getActionChildren(desktopActionId),
560
+ }
561
+ }
562
+ // Sidepanel tools → default action (toggle panel)
563
+ else if (tool.render === 'sidepanel') {
564
+ actions.push({
565
+ id: mobileId,
566
+ label: tool.ariaLabel || tool.label || key,
567
+ type: 'default',
568
+ modes: tool.modes || ['*'],
569
+ excludeRoutes: tool.excludeRoutes,
570
+ toolKey: key,
571
+ localOnly: !tool.prod,
572
+ })
573
+ handlers[mobileId] = () => { togglePanel(tool.sidepanel) }
574
+ }
575
+ // Button tools (e.g. comments) — only if the desktop guard passed (component loaded)
576
+ else if (tool.render === 'button' && toolComponents[key]) {
577
+ try {
578
+ if (key === 'comments') {
579
+ const { toggleCommentMode, isCommentModeActive } = await import('./comments/commentMode.js')
580
+ const { isAuthenticated } = await import('./comments/auth.js')
581
+ const { openAuthModal } = await import('./comments/ui/authModal.js')
582
+ actions.push({
583
+ id: mobileId,
584
+ label: tool.ariaLabel || 'Comments',
585
+ type: 'toggle',
586
+ modes: tool.modes || ['*'],
587
+ excludeRoutes: tool.excludeRoutes,
588
+ toolKey: key,
589
+ localOnly: !tool.prod,
590
+ })
591
+ handlers[mobileId] = {
592
+ execute: async () => {
593
+ if (!isAuthenticated()) {
594
+ const user = await openAuthModal()
595
+ if (!user) return
596
+ }
597
+ toggleCommentMode()
598
+ },
599
+ getState: () => isCommentModeActive(),
600
+ }
601
+ }
602
+ } catch { /* comments module not available */ }
603
+ }
604
+ }
605
+
606
+ // Theme tool — special handling (component-only, no handler in registry)
607
+ if (toolConfigs.theme && toolComponents.theme) {
608
+ // Only add if not already handled above (theme has no getChildren in registry)
609
+ if (!actions.some(a => a.id === 'mobile:theme')) {
610
+ try {
611
+ const { themeState, setTheme, THEMES } = await import('./stores/themeStore.js')
612
+ actions.push({
613
+ id: 'mobile:theme',
614
+ label: 'Theme',
615
+ type: 'submenu',
616
+ modes: ['*'],
617
+ toolKey: 'theme',
618
+ localOnly: !toolConfigs.theme.prod,
619
+ })
620
+ handlers['mobile:theme'] = {
621
+ getChildren: () => {
622
+ const current = themeState.theme
623
+ return THEMES.map((t: any) => ({
624
+ id: `theme:${t.value}`,
625
+ label: t.label,
626
+ type: 'toggle' as const,
627
+ active: current === t.value,
628
+ execute: () => setTheme(t.value),
629
+ }))
630
+ },
631
+ }
632
+ } catch { /* theme store not available */ }
633
+ }
634
+ }
635
+
636
+ // Canvas toolbar stays visible on mobile — no need to duplicate canvas tools here
637
+
638
+ setDynamicActions('mobile-toolbar', actions, handlers)
639
+ mobileActionsRegistered = true
640
+ }
641
+
498
642
  // Flow info dialog state — driven by core/show-flow-info action
499
643
  let flowDialogOpen = $state(false)
500
644
  let flowName = $state('default')
@@ -557,7 +701,7 @@
557
701
  onkeydown={handleToolbarKeydown}
558
702
  bind:this={toolbarEl}
559
703
  >
560
- {#if visible}
704
+ {#if visible && !isMobileState}
561
705
  {#each cleanedMenus as menu, i (menu.key)}
562
706
  {#if menu.render === 'separator'}
563
707
  <div class="toolbar-separator" aria-hidden="true"></div>
@@ -592,6 +736,7 @@
592
736
  data={toolData[menu.key]}
593
737
  tabindex={getTabindex(i)}
594
738
  localOnly={isToolbarToolLocalOnly(menu.key)}
739
+ {basePath}
595
740
  />
596
741
  </span>
597
742
  {/if}
@@ -618,6 +763,10 @@
618
763
  <SidePanel onClose={() => focusToolbarItem(activeToolbarIndex < 0 ? toolbarItemCount - 1 : activeToolbarIndex)} />
619
764
  {/if}
620
765
 
766
+ {#if !isEmbed}
767
+ <PwaInstallBanner />
768
+ {/if}
769
+
621
770
  <style>
622
771
  .toolbar-separator {
623
772
  width: 1px;