@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- 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
|
package/scaffold/manifest.json
CHANGED
|
@@ -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
|
}
|
|
@@ -81,7 +81,12 @@
|
|
|
81
81
|
{#if child.type === 'radio'}
|
|
82
82
|
<DropdownMenu.RadioItem
|
|
83
83
|
value={child.id}
|
|
84
|
-
onclick={() => {
|
|
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={() => {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{wt.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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>
|
package/src/CanvasSnap.svelte
CHANGED
package/src/CoreUIBar.svelte
CHANGED
|
@@ -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;
|