@marver-design/marver 0.2.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.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +38 -0
- package/dist/build-D0GnIR4G.mjs +244 -0
- package/dist/cli.mjs +58 -0
- package/dist/config-DMBEpdEN.mjs +132 -0
- package/dist/dev-BK4x3PBr.mjs +76 -0
- package/dist/init-DcOy1krf.mjs +168 -0
- package/dist/plugin-DB5t2WUl.mjs +437 -0
- package/dist/serve-BPNmWeJx.mjs +200 -0
- package/package.json +59 -0
- package/src/client/const.ts +5 -0
- package/src/client/frame-host/bridge.js +37 -0
- package/src/client/frame-host/index.html +11 -0
- package/src/client/frame-host/main.tsx +73 -0
- package/src/client/frame-host/registry.ts +40 -0
- package/src/client/runtime/index.ts +8 -0
- package/src/client/shell/App.tsx +633 -0
- package/src/client/shell/Play.tsx +399 -0
- package/src/client/shell/Tip.tsx +43 -0
- package/src/client/shell/canvas/Canvas.tsx +285 -0
- package/src/client/shell/canvas/FrameNode.tsx +194 -0
- package/src/client/shell/favicon/android-chrome-192x192-interactive.png +0 -0
- package/src/client/shell/favicon/android-chrome-192x192.png +0 -0
- package/src/client/shell/favicon/android-chrome-512x512-interactive.png +0 -0
- package/src/client/shell/favicon/android-chrome-512x512.png +0 -0
- package/src/client/shell/favicon/apple-touch-icon-interactive.png +0 -0
- package/src/client/shell/favicon/apple-touch-icon.png +0 -0
- package/src/client/shell/favicon/favicon-16x16-interactive.png +0 -0
- package/src/client/shell/favicon/favicon-16x16.png +0 -0
- package/src/client/shell/favicon/favicon-32x32-interactive.png +0 -0
- package/src/client/shell/favicon/favicon-32x32.png +0 -0
- package/src/client/shell/favicon/favicon.ico +0 -0
- package/src/client/shell/favicon/site.webmanifest +11 -0
- package/src/client/shell/hash.ts +67 -0
- package/src/client/shell/icons.tsx +53 -0
- package/src/client/shell/index.html +17 -0
- package/src/client/shell/main.tsx +9 -0
- package/src/client/shell/store.ts +540 -0
- package/src/client/shell/styles.css +508 -0
- package/src/client/shell/tidy.ts +24 -0
- package/src/client/stage/index.html +11 -0
- package/src/client/stage/main.tsx +160 -0
- package/templates/AGENTS-embedded.md +76 -0
- package/templates/AGENTS-studio.md +76 -0
- package/templates/demo/_fixtures.ts +9 -0
- package/templates/demo/dark.tsx +17 -0
- package/templates/demo/form.tsx +51 -0
- package/templates/demo/welcome.tsx +21 -0
- package/templates/design-tsconfig.json +8 -0
- package/templates/root-layout.tsx +7 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Marver</title>
|
|
7
|
+
<link rel="icon" href="{{ROUTE}}/favicon/favicon.ico" sizes="48x48" />
|
|
8
|
+
<link rel="icon" type="image/png" sizes="32x32" href="{{ROUTE}}/favicon/favicon-32x32.png" />
|
|
9
|
+
<link rel="icon" type="image/png" sizes="16x16" href="{{ROUTE}}/favicon/favicon-16x16.png" />
|
|
10
|
+
<link rel="apple-touch-icon" href="{{ROUTE}}/favicon/apple-touch-icon.png" />
|
|
11
|
+
<link rel="manifest" href="{{ROUTE}}/favicon/site.webmanifest" />
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<script type="module" src="{{ENTRY}}"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client'
|
|
2
|
+
import { App, ShellBoundary } from './App.tsx'
|
|
3
|
+
import { useStore } from './store.ts'
|
|
4
|
+
import './styles.css'
|
|
5
|
+
|
|
6
|
+
// dev aid: inspectable store (harmless in builds; the canvas is a dev surface)
|
|
7
|
+
;(window as any).__sh = useStore
|
|
8
|
+
|
|
9
|
+
createRoot(document.getElementById('root')!).render(<ShellBoundary><App /></ShellBoundary>)
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { ROUTE } from '../const.ts'
|
|
3
|
+
import { tidy } from './tidy.ts'
|
|
4
|
+
// @ts-expect-error virtual module provided by the plugin
|
|
5
|
+
import shConfig from 'virtual:sh-config'
|
|
6
|
+
// @ts-expect-error virtual module: null in dev; a published build inlines manifest+boards
|
|
7
|
+
import shData from 'virtual:sh-data'
|
|
8
|
+
|
|
9
|
+
/** Published-build data. Non-null = static site: no API, no saves, no live events.
|
|
10
|
+
* `names` is the switcher list (all-scenes only when actually published); `default`
|
|
11
|
+
* is where `/` opens - never a synthesized aggregate of a filtered build. */
|
|
12
|
+
const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
|
|
13
|
+
|
|
14
|
+
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string }
|
|
15
|
+
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
16
|
+
export interface Node {
|
|
17
|
+
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
18
|
+
/** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
|
|
19
|
+
theme: string
|
|
20
|
+
/** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
|
|
21
|
+
* The only theme value that persists into the board file. */
|
|
22
|
+
themeUser?: string
|
|
23
|
+
status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
|
|
24
|
+
}
|
|
25
|
+
export interface Toast { id: number; text: string }
|
|
26
|
+
|
|
27
|
+
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean } = shConfig
|
|
28
|
+
|
|
29
|
+
export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
30
|
+
|
|
31
|
+
/** Board names for switchers: all-scenes first, the rest sorted. Throws on transport
|
|
32
|
+
* failure - callers keep their last known list. */
|
|
33
|
+
export async function fetchBoardNames(): Promise<string[]> {
|
|
34
|
+
if (DATA) return DATA.names
|
|
35
|
+
const list: { name: string }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
|
|
36
|
+
return ['all-scenes', ...list.map((b) => b.name).filter((n) => n !== 'all-scenes').sort()]
|
|
37
|
+
}
|
|
38
|
+
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
39
|
+
export const boardLabel = (n: string) => (n === 'all-scenes' ? 'All scenes' : cap(n))
|
|
40
|
+
|
|
41
|
+
const HEADER = 28
|
|
42
|
+
let toastSeq = 0
|
|
43
|
+
const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
|
|
44
|
+
|
|
45
|
+
export function frameUrl(frame: FrameEntry, theme: string): string {
|
|
46
|
+
return frame.kind === 'html'
|
|
47
|
+
? `/${frame.file}?theme=${theme}`
|
|
48
|
+
: `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The global view theme: the user's sticky preference, applied across boards and
|
|
52
|
+
* reloads. Frames resolve to it unless they declare meta.theme or carry a user pin. */
|
|
53
|
+
const initialViewTheme = () => {
|
|
54
|
+
try {
|
|
55
|
+
const t = localStorage.getItem('mv-view-theme')
|
|
56
|
+
if (t && CONFIG.themes.includes(t)) return t
|
|
57
|
+
} catch { /* storage unavailable */ }
|
|
58
|
+
return CONFIG.themes[0] ?? 'light'
|
|
59
|
+
}
|
|
60
|
+
const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
|
|
61
|
+
|
|
62
|
+
function defaultSize(frame: FrameEntry) {
|
|
63
|
+
const vp = CONFIG.viewports[frame.viewport ?? ''] ?? CONFIG.viewports.mobile ?? { width: 390, height: 844 }
|
|
64
|
+
return { w: vp.width, h: vp.height }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface State {
|
|
68
|
+
manifest: Manifest | null
|
|
69
|
+
nodes: Node[] // append-only order; NEVER sorted or reordered (iframe law G-1)
|
|
70
|
+
selection: string[] // ordered; last entry is the primary (bar anchor)
|
|
71
|
+
interact: string | null
|
|
72
|
+
viewTheme: string // the global theme preference; sticky across boards and reloads
|
|
73
|
+
play: { at: string; device: string; theme: string } | null // play mode (SPEC-M2 §1); at = current frame id
|
|
74
|
+
gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
|
|
75
|
+
board: string // active board name; 'all-scenes' is the auto board
|
|
76
|
+
boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
|
|
77
|
+
deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
|
|
78
|
+
baseLayout: Record<string, { x: number; y: number; w: number; h: number }> | null // snapshot taken on entering a device view; Default restores it exactly
|
|
79
|
+
panelOpen: boolean
|
|
80
|
+
scale: number
|
|
81
|
+
toasts: Toast[]
|
|
82
|
+
boardHash: string | null // sha256 of the board file on disk, when materialized
|
|
83
|
+
dirty: boolean
|
|
84
|
+
|
|
85
|
+
boot(): Promise<boolean>
|
|
86
|
+
applyManifest(m: Manifest): void
|
|
87
|
+
frameFor(node: Node): FrameEntry | undefined
|
|
88
|
+
moveNode(key: string, x: number, y: number): void
|
|
89
|
+
resizeNode(key: string, w: number, h: number): void
|
|
90
|
+
setStatus(key: string, status: Node['status'], error?: string): void
|
|
91
|
+
removeNode(key: string): void
|
|
92
|
+
select(key: string | null, additive?: boolean): void
|
|
93
|
+
selectAll(): void
|
|
94
|
+
setInteract(key: string | null): void
|
|
95
|
+
setPlay(p: State['play']): void
|
|
96
|
+
setGesture(g: boolean): void
|
|
97
|
+
moveSelectedBy(dx: number, dy: number, starts: Record<string, { x: number; y: number }>): void
|
|
98
|
+
setSelectedTheme(theme: string): void
|
|
99
|
+
setDeviceView(name: string | null): void
|
|
100
|
+
resizeSelected(name: string | null): void
|
|
101
|
+
switchBoard(name: string): Promise<void>
|
|
102
|
+
setScale(s: number): void
|
|
103
|
+
togglePanel(): void
|
|
104
|
+
setTheme(theme: string): void
|
|
105
|
+
runTidy(): void
|
|
106
|
+
toast(text: string): void
|
|
107
|
+
spawn(frameId: string): Node | null
|
|
108
|
+
save(): Promise<boolean>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const useStore = create<State>((set, get) => {
|
|
112
|
+
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
|
113
|
+
let saveChain: Promise<boolean> = Promise.resolve(true) // saves are serialized; responses only commit if the board is still active
|
|
114
|
+
let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit
|
|
115
|
+
let loadSeq = 0 // stale boot() responses never overwrite a newer board
|
|
116
|
+
let switchSeq = 0 // last click wins when board switches race
|
|
117
|
+
const scheduleSave = () => { editRev++; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
|
|
118
|
+
|
|
119
|
+
/** Theme resolution ladder: user pin > the frame's declared meta.theme > viewTheme. */
|
|
120
|
+
const resolveTheme = (frame?: FrameEntry, user?: string) => user ?? frame?.theme ?? get().viewTheme
|
|
121
|
+
|
|
122
|
+
/** Published builds are read-only on disk, so pins persist to sessionStorage instead -
|
|
123
|
+
* parity with dev within a visit (keyed by frame id: virtual boards mint node keys
|
|
124
|
+
* per load, frame ids are stable). */
|
|
125
|
+
const sessionPins = {
|
|
126
|
+
read(board: string): Record<string, string> {
|
|
127
|
+
try { return JSON.parse(sessionStorage.getItem(`mv-pins-${board}`) ?? '{}') } catch { return {} }
|
|
128
|
+
},
|
|
129
|
+
write(board: string, nodes: Node[]) {
|
|
130
|
+
try {
|
|
131
|
+
const pins: Record<string, string> = {}
|
|
132
|
+
for (const n of nodes) if (n.themeUser) pins[n.frame] = n.themeUser
|
|
133
|
+
sessionStorage.setItem(`mv-pins-${board}`, JSON.stringify(pins))
|
|
134
|
+
} catch { /* storage unavailable */ }
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Fetch + normalize a board into ready-to-commit state, WITHOUT touching the store.
|
|
139
|
+
* null = failure (transport, malformed manifest, non-404 board error) - the caller
|
|
140
|
+
* keeps whatever board is currently mounted. */
|
|
141
|
+
const loadBoardState = async (boardName: string): Promise<Partial<State> | null> => {
|
|
142
|
+
try {
|
|
143
|
+
let raw: any
|
|
144
|
+
if (DATA) raw = DATA.manifest
|
|
145
|
+
else {
|
|
146
|
+
const mRes = await fetch('/design/manifest.json')
|
|
147
|
+
if (!mRes.ok) return null
|
|
148
|
+
raw = await mRes.json().catch(() => undefined)
|
|
149
|
+
}
|
|
150
|
+
if (raw === undefined || raw === null || typeof raw !== 'object') return null
|
|
151
|
+
const manifest: Manifest = {
|
|
152
|
+
frames: (Array.isArray(raw?.frames) ? raw.frames : [])
|
|
153
|
+
.filter((f: any) => f && typeof f.id === 'string' && typeof f.file === 'string'),
|
|
154
|
+
scenes: Array.isArray(raw?.scenes) ? raw.scenes : [],
|
|
155
|
+
}
|
|
156
|
+
let nodes: Node[] = []
|
|
157
|
+
let boardHash: string | null = null
|
|
158
|
+
let boardAuto = boardName === 'all-scenes'
|
|
159
|
+
let deviceView: string | null = null
|
|
160
|
+
let baseLayout: State['baseLayout'] = null
|
|
161
|
+
let needTidy = false
|
|
162
|
+
// published build: boards come from the inlined data; absent = fresh (the 404 path)
|
|
163
|
+
let loaded: { board: any; sha256: string } | 'fresh' | null
|
|
164
|
+
if (DATA) loaded = DATA.boards[boardName] ? { board: DATA.boards[boardName], sha256: 'published' } : 'fresh'
|
|
165
|
+
else {
|
|
166
|
+
const res = await fetch(`${ROUTE}/api/boards/${boardName}`)
|
|
167
|
+
if (res.ok) loaded = await res.json()
|
|
168
|
+
else if (res.status === 404) loaded = 'fresh'
|
|
169
|
+
else loaded = null // only 404 means "fresh board"; anything else must not commit an empty canvas
|
|
170
|
+
}
|
|
171
|
+
if (loaded === null) return null
|
|
172
|
+
if (loaded !== 'fresh') {
|
|
173
|
+
const { board, sha256 } = loaded
|
|
174
|
+
boardHash = sha256
|
|
175
|
+
if (typeof board?.auto === 'boolean') boardAuto = board.auto
|
|
176
|
+
// Agent-authored boards may be just a frame list - fill sizes/keys, tidy on first load.
|
|
177
|
+
// Keys are load-bearing (iframe identity, selection) - dupes/blanks get reminted.
|
|
178
|
+
// Board nodes whose file is gone stay, flagged - deletion is surfaced, never silent (spec §7).
|
|
179
|
+
const seenKeys = new Set<string>()
|
|
180
|
+
nodes = (Array.isArray(board?.nodes) ? board.nodes : [])
|
|
181
|
+
.filter((n: any) => n && typeof n.frame === 'string')
|
|
182
|
+
.map((n: any) => {
|
|
183
|
+
const f = manifest.frames.find((x) => x.id === n.frame)
|
|
184
|
+
const d = f ? defaultSize(f) : { w: 390, h: 844 }
|
|
185
|
+
if (typeof n.x !== 'number' || typeof n.y !== 'number') needTidy = true
|
|
186
|
+
let key = typeof n.key === 'string' && n.key ? n.key : nodeKey()
|
|
187
|
+
if (seenKeys.has(key)) key = nodeKey()
|
|
188
|
+
seenKeys.add(key)
|
|
189
|
+
return {
|
|
190
|
+
key,
|
|
191
|
+
frame: n.frame,
|
|
192
|
+
x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0,
|
|
193
|
+
w: typeof n.w === 'number' ? n.w : d.w, h: typeof n.h === 'number' ? n.h : d.h,
|
|
194
|
+
// pins persist as their own field (exact round-trip). Legacy boards stored a
|
|
195
|
+
// theme on EVERY node: only values differing from the frame's static default
|
|
196
|
+
// were deliberate - the rest follow viewTheme
|
|
197
|
+
...(() => {
|
|
198
|
+
const stored = typeof n.theme === 'string' ? n.theme : undefined
|
|
199
|
+
const themeUser = typeof n.themeUser === 'string' && CONFIG.themes.includes(n.themeUser)
|
|
200
|
+
? n.themeUser
|
|
201
|
+
: stored && stored !== (f?.theme ?? CONFIG.themes[0] ?? 'light') ? stored : undefined
|
|
202
|
+
return { theme: resolveTheme(f, themeUser), themeUser }
|
|
203
|
+
})(),
|
|
204
|
+
status: 'loading' as const,
|
|
205
|
+
missing: !manifest.frames.some((f2) => f2.id === n.frame),
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
// device view and the free-form snapshot survive reloads - independently:
|
|
209
|
+
// a scoped "exception" clears deviceView but the snapshot must keep restoring
|
|
210
|
+
if (typeof board?.deviceView === 'string' && CONFIG.viewports[board.deviceView]) deviceView = board.deviceView
|
|
211
|
+
if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
|
|
212
|
+
}
|
|
213
|
+
// frames not on the board yet → auto boards only (a curated board shows exactly its list)
|
|
214
|
+
if (boardAuto) {
|
|
215
|
+
const placed = new Set(nodes.map((n) => n.frame))
|
|
216
|
+
for (const f of manifest.frames) {
|
|
217
|
+
if (placed.has(f.id)) continue
|
|
218
|
+
placed.add(f.id)
|
|
219
|
+
const d = defaultSize(f)
|
|
220
|
+
const vp = deviceView ? CONFIG.viewports[deviceView] : null
|
|
221
|
+
nodes.push({ key: nodeKey(), frame: f.id, x: 0, y: 0, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// published: re-apply this visit's pins over the inlined data
|
|
225
|
+
if (DATA && nodes.length) {
|
|
226
|
+
const pins = sessionPins.read(boardName)
|
|
227
|
+
for (const n of nodes) {
|
|
228
|
+
const pin = pins[n.frame]
|
|
229
|
+
if (pin && CONFIG.themes.includes(pin)) { n.themeUser = pin; n.theme = pin }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if ((!boardHash || needTidy) && nodes.length) {
|
|
233
|
+
const sceneOf = (id: string) => manifest.frames.find((f) => f.id === id)?.scene ?? ''
|
|
234
|
+
const placedAll = tidy(nodes.map((n) => ({ key: n.key, scene: sceneOf(n.frame), w: n.w, h: n.h + HEADER })))
|
|
235
|
+
for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
|
|
236
|
+
}
|
|
237
|
+
// dirty: false - the committed state matches disk by construction
|
|
238
|
+
return { manifest, nodes, boardHash, boardAuto, deviceView, baseLayout, selection: [], dirty: false }
|
|
239
|
+
} catch { return null }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
|
|
244
|
+
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, baseLayout: null,
|
|
245
|
+
panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
|
|
246
|
+
|
|
247
|
+
async boot() {
|
|
248
|
+
const seq = ++loadSeq
|
|
249
|
+
const boardName = get().board
|
|
250
|
+
const revAtStart = editRev
|
|
251
|
+
const next = await loadBoardState(boardName)
|
|
252
|
+
if (seq !== loadSeq) return false // a newer load superseded this one
|
|
253
|
+
if (!next) { get().toast(`board "${boardName}" failed to load`); return false }
|
|
254
|
+
// the user kept editing while we fetched - their newer state wins over the reload
|
|
255
|
+
if (get().board !== boardName || editRev !== revAtStart) return false
|
|
256
|
+
const live = get().manifest // a WS manifest update may have landed mid-fetch
|
|
257
|
+
set(next)
|
|
258
|
+
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
259
|
+
return true
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async switchBoard(name) {
|
|
263
|
+
const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
|
|
264
|
+
if (name === get().board) return
|
|
265
|
+
// flush loop: an edit landing mid-save keeps dirty set and gets its own pass
|
|
266
|
+
let ok = true
|
|
267
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
268
|
+
if (mySwitch !== switchSeq) return
|
|
269
|
+
if (get().dirty) { get().toast('current board could not be saved - staying here'); return }
|
|
270
|
+
// load the target while the current board stays mounted; commit atomically on success -
|
|
271
|
+
// there is never an empty intermediate state and nothing to roll back
|
|
272
|
+
const next = await loadBoardState(name)
|
|
273
|
+
if (mySwitch !== switchSeq) return
|
|
274
|
+
if (!next) { get().toast(`could not load "${name}" - staying on ${get().board}`); return }
|
|
275
|
+
// edits may have landed on the still-mounted old board during the target load
|
|
276
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
277
|
+
if (mySwitch !== switchSeq) return
|
|
278
|
+
if (get().dirty) { get().toast('current board could not be saved - staying here'); return }
|
|
279
|
+
clearTimeout(saveTimer) // a scheduled-but-clean timer must not fire against the new board
|
|
280
|
+
++loadSeq // invalidate any in-flight boot of the old board
|
|
281
|
+
const live = get().manifest // a WS manifest update may have landed mid-load
|
|
282
|
+
set({ board: name, interact: null, ...next })
|
|
283
|
+
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
applyManifest(m) {
|
|
287
|
+
const { nodes, toast, boardAuto } = get()
|
|
288
|
+
const known = new Set(nodes.map((n) => n.frame))
|
|
289
|
+
const next = [...nodes]
|
|
290
|
+
let changed = false
|
|
291
|
+
const { deviceView, baseLayout } = get()
|
|
292
|
+
const vp = deviceView ? CONFIG.viewports[deviceView] : null
|
|
293
|
+
let nextBase = baseLayout
|
|
294
|
+
for (const f of m.frames) {
|
|
295
|
+
if (!boardAuto) break // curated boards never auto-gain frames
|
|
296
|
+
if (known.has(f.id)) continue
|
|
297
|
+
const d = defaultSize(f)
|
|
298
|
+
const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
|
|
299
|
+
const node = { key: nodeKey(), frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const }
|
|
300
|
+
next.push(node)
|
|
301
|
+
// in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
|
|
302
|
+
if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
|
|
303
|
+
toast(`agent added ${f.id}`)
|
|
304
|
+
changed = true
|
|
305
|
+
}
|
|
306
|
+
const live = new Set(m.frames.map((f) => f.id))
|
|
307
|
+
let retinted = false
|
|
308
|
+
for (const n of next) {
|
|
309
|
+
const missing = !live.has(n.frame)
|
|
310
|
+
if (missing !== !!n.missing) { n.missing = missing; changed = true }
|
|
311
|
+
// a frame's declared meta.theme may have changed - re-resolve unpinned nodes
|
|
312
|
+
// (derived value: re-render yes, dirty/save no)
|
|
313
|
+
const f = m.frames.find((x) => x.id === n.frame)
|
|
314
|
+
const want = n.themeUser ?? f?.theme ?? get().viewTheme
|
|
315
|
+
if (n.theme !== want) { n.theme = want; retinted = true }
|
|
316
|
+
}
|
|
317
|
+
set({ manifest: m, nodes: changed || retinted ? [...next] : next, ...(changed ? { dirty: true, baseLayout: nextBase } : {}) })
|
|
318
|
+
if (changed) scheduleSave()
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
removeNode(key) {
|
|
322
|
+
set((s) => ({
|
|
323
|
+
nodes: s.nodes.filter((n) => n.key !== key),
|
|
324
|
+
selection: s.selection.filter((k) => k !== key),
|
|
325
|
+
interact: s.interact === key ? null : s.interact,
|
|
326
|
+
dirty: true,
|
|
327
|
+
}))
|
|
328
|
+
scheduleSave()
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
frameFor(node) { return get().manifest?.frames.find((f) => f.id === node.frame) },
|
|
332
|
+
|
|
333
|
+
moveNode(key, x, y) {
|
|
334
|
+
set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, x, y } : n)), dirty: true }))
|
|
335
|
+
scheduleSave()
|
|
336
|
+
},
|
|
337
|
+
// group drag: every selected node moves by the same delta from its gesture-start position
|
|
338
|
+
moveSelectedBy(dx, dy, starts) {
|
|
339
|
+
set((s) => ({
|
|
340
|
+
nodes: s.nodes.map((n) => (starts[n.key] ? { ...n, x: starts[n.key].x + dx, y: starts[n.key].y + dy } : n)),
|
|
341
|
+
dirty: true,
|
|
342
|
+
}))
|
|
343
|
+
scheduleSave()
|
|
344
|
+
},
|
|
345
|
+
// scoped theme = an explicit per-frame PIN; it survives board reloads and yields
|
|
346
|
+
// only to the next global set
|
|
347
|
+
setSelectedTheme(theme) {
|
|
348
|
+
set((s) => {
|
|
349
|
+
const sel = new Set(s.selection)
|
|
350
|
+
return { nodes: s.nodes.map((n) => (sel.has(n.key) ? { ...n, theme, themeUser: theme } : n)), dirty: true }
|
|
351
|
+
})
|
|
352
|
+
scheduleSave()
|
|
353
|
+
},
|
|
354
|
+
resizeNode(key, w, h) {
|
|
355
|
+
// a manual resize means the board no longer uniformly shows one device; the resized
|
|
356
|
+
// frame's snapshot entry follows the user's new size so Default will not undo it
|
|
357
|
+
set((s) => {
|
|
358
|
+
const W = Math.max(120, w), H = Math.max(80, h)
|
|
359
|
+
const cur = s.nodes.find((n) => n.key === key)
|
|
360
|
+
return {
|
|
361
|
+
nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H } : n)),
|
|
362
|
+
dirty: true,
|
|
363
|
+
deviceView: null,
|
|
364
|
+
baseLayout: s.baseLayout && cur
|
|
365
|
+
? { ...s.baseLayout, [key]: { ...(s.baseLayout[key] ?? { x: cur.x, y: cur.y }), w: W, h: H } }
|
|
366
|
+
: s.baseLayout,
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
scheduleSave()
|
|
370
|
+
},
|
|
371
|
+
setDeviceView(name) {
|
|
372
|
+
const vp = name ? CONFIG.viewports[name] : null
|
|
373
|
+
if (name && !vp) return
|
|
374
|
+
set((s) => {
|
|
375
|
+
// entering a device view from free-form: snapshot the layout so Default restores it
|
|
376
|
+
const baseLayout = name
|
|
377
|
+
? (s.deviceView === null
|
|
378
|
+
? Object.fromEntries(s.nodes.map((n) => [n.key, { x: n.x, y: n.y, w: n.w, h: n.h }]))
|
|
379
|
+
: s.baseLayout)
|
|
380
|
+
: null
|
|
381
|
+
const nodes = s.nodes.map((n) => {
|
|
382
|
+
if (vp) return { ...n, w: vp.width, h: vp.height }
|
|
383
|
+
const b = s.baseLayout?.[n.key]
|
|
384
|
+
if (b) return { ...n, ...b } // exact free-form layout, positions included
|
|
385
|
+
const f = s.manifest?.frames.find((x) => x.id === n.frame)
|
|
386
|
+
if (!f) return n
|
|
387
|
+
const d = defaultSize(f) // frames added mid-device-view get their default
|
|
388
|
+
return { ...n, w: d.w, h: d.h }
|
|
389
|
+
})
|
|
390
|
+
return { deviceView: name, dirty: true, baseLayout, nodes }
|
|
391
|
+
})
|
|
392
|
+
if (name) get().runTidy() // restore must NOT tidy - it would destroy positions
|
|
393
|
+
else scheduleSave()
|
|
394
|
+
},
|
|
395
|
+
setStatus(key, status, error) {
|
|
396
|
+
set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, status, error } : n)) }))
|
|
397
|
+
},
|
|
398
|
+
// plain select replaces; additive toggles membership. Interact survives only while its
|
|
399
|
+
// frame stays selected.
|
|
400
|
+
select(key, additive = false) {
|
|
401
|
+
set((s) => {
|
|
402
|
+
const selection = key == null
|
|
403
|
+
? []
|
|
404
|
+
: additive
|
|
405
|
+
? (s.selection.includes(key) ? s.selection.filter((k) => k !== key) : [...s.selection, key])
|
|
406
|
+
: [key]
|
|
407
|
+
return { selection, interact: s.interact && selection.includes(s.interact) ? s.interact : null }
|
|
408
|
+
})
|
|
409
|
+
},
|
|
410
|
+
// scoped device sizing: only the selected frames change; null restores their defaults.
|
|
411
|
+
// Always followed by a tidy - a preset change must never scramble the layout.
|
|
412
|
+
resizeSelected(name) {
|
|
413
|
+
const vp = name ? CONFIG.viewports[name] : null
|
|
414
|
+
if (name && !vp) return
|
|
415
|
+
set((s) => {
|
|
416
|
+
const sel = new Set(s.selection)
|
|
417
|
+
const nodes = s.nodes.map((n) => {
|
|
418
|
+
if (!sel.has(n.key)) return n
|
|
419
|
+
if (vp) return { ...n, w: vp.width, h: vp.height }
|
|
420
|
+
const f = s.manifest?.frames.find((x) => x.id === n.frame)
|
|
421
|
+
if (!f) return n
|
|
422
|
+
const d = defaultSize(f)
|
|
423
|
+
return { ...n, w: d.w, h: d.h }
|
|
424
|
+
})
|
|
425
|
+
const baseLayout = s.baseLayout ? { ...s.baseLayout } : null
|
|
426
|
+
if (baseLayout) for (const k of s.selection) {
|
|
427
|
+
const n = nodes.find((x) => x.key === k)
|
|
428
|
+
if (n) baseLayout[k] = { ...(baseLayout[k] ?? { x: n.x, y: n.y }), w: n.w, h: n.h }
|
|
429
|
+
}
|
|
430
|
+
return { nodes, dirty: true, deviceView: null, baseLayout }
|
|
431
|
+
})
|
|
432
|
+
get().runTidy()
|
|
433
|
+
},
|
|
434
|
+
selectAll() {
|
|
435
|
+
set((s) => ({ selection: s.nodes.map((n) => n.key) }))
|
|
436
|
+
},
|
|
437
|
+
// interact is a ONE-frame mode: entering it collapses any multi-selection to the
|
|
438
|
+
// interacted frame (a 4-frame selection double-clicked otherwise leaves all four
|
|
439
|
+
// painted as "interactive"). Exiting keeps the frame selected for continuity.
|
|
440
|
+
setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
|
|
441
|
+
setPlay(play) { set({ play }) },
|
|
442
|
+
setGesture(gesture) { set({ gesture }) },
|
|
443
|
+
setScale(scale) { set({ scale }) },
|
|
444
|
+
togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
|
|
445
|
+
// global theme = the VIEW preference: persists across boards + reloads, clears
|
|
446
|
+
// per-frame pins. Frames declaring meta.theme keep their mode (they only work there).
|
|
447
|
+
setTheme(theme) {
|
|
448
|
+
try { localStorage.setItem('mv-view-theme', theme) } catch { /* storage unavailable */ }
|
|
449
|
+
set((s) => ({
|
|
450
|
+
viewTheme: theme,
|
|
451
|
+
nodes: s.nodes.map((n) => {
|
|
452
|
+
const f = s.manifest?.frames.find((x) => x.id === n.frame)
|
|
453
|
+
return { ...n, themeUser: undefined, theme: f?.theme ?? theme }
|
|
454
|
+
}),
|
|
455
|
+
dirty: true,
|
|
456
|
+
}))
|
|
457
|
+
scheduleSave()
|
|
458
|
+
},
|
|
459
|
+
runTidy() {
|
|
460
|
+
const { nodes, manifest } = get()
|
|
461
|
+
const sceneOf = (id: string) => manifest?.frames.find((f) => f.id === id)?.scene ?? ''
|
|
462
|
+
const placed = tidy(nodes.map((n) => ({ key: n.key, scene: sceneOf(n.frame), w: n.w, h: n.h + HEADER })))
|
|
463
|
+
set((s) => ({
|
|
464
|
+
nodes: s.nodes.map((n) => {
|
|
465
|
+
const p = placed.find((x) => x.key === n.key)
|
|
466
|
+
return p ? { ...n, x: p.x, y: p.y } : n
|
|
467
|
+
}),
|
|
468
|
+
dirty: true,
|
|
469
|
+
}))
|
|
470
|
+
scheduleSave()
|
|
471
|
+
},
|
|
472
|
+
toast(text) {
|
|
473
|
+
const id = ++toastSeq
|
|
474
|
+
set((s) => ({ toasts: [...s.toasts, { id, text }] }))
|
|
475
|
+
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 4000)
|
|
476
|
+
},
|
|
477
|
+
spawn(frameId) {
|
|
478
|
+
const { manifest, nodes, deviceView, baseLayout } = get()
|
|
479
|
+
const f = manifest?.frames.find((x) => x.id === frameId)
|
|
480
|
+
if (!f) return null
|
|
481
|
+
const d = defaultSize(f)
|
|
482
|
+
const vp = deviceView ? CONFIG.viewports[deviceView] : null
|
|
483
|
+
const maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
|
|
484
|
+
const node: Node = { key: nodeKey(), frame: f.id, x: maxX + 96, y: 0, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' }
|
|
485
|
+
set((s) => ({
|
|
486
|
+
nodes: [...s.nodes, node],
|
|
487
|
+
dirty: true,
|
|
488
|
+
...(vp && baseLayout ? { baseLayout: { ...baseLayout, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } } } : {}),
|
|
489
|
+
}))
|
|
490
|
+
scheduleSave()
|
|
491
|
+
get().toast(`added ${f.id}`)
|
|
492
|
+
return node
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
save() {
|
|
496
|
+
// a published canvas is read-only ON DISK (spec §12): theme pins persist to the
|
|
497
|
+
// session instead, and dirty must clear or switchBoard's save-flush loop wedges
|
|
498
|
+
if (DATA) {
|
|
499
|
+
sessionPins.write(get().board, get().nodes)
|
|
500
|
+
set({ dirty: false })
|
|
501
|
+
return Promise.resolve(true)
|
|
502
|
+
}
|
|
503
|
+
const p = saveChain.then(async () => {
|
|
504
|
+
const rev = editRev
|
|
505
|
+
const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
|
|
506
|
+
// Missing nodes persist too - only the explicit remove button drops them (spec §7).
|
|
507
|
+
const board = {
|
|
508
|
+
version: 1, name: boardName, auto: boardAuto,
|
|
509
|
+
// deviceView and the free-form snapshot persist independently: a scoped
|
|
510
|
+
// exception clears the view but 0 must still restore the snapshot after reload
|
|
511
|
+
...(deviceView ? { deviceView } : {}),
|
|
512
|
+
...(baseLayout ? { baseLayout } : {}),
|
|
513
|
+
// only PINNED themes persist - inherited values follow viewTheme at load time
|
|
514
|
+
nodes: nodes.map(({ key, frame, x, y, w, h, themeUser }) => ({ key, frame, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), ...(themeUser ? { themeUser } : {}) })),
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const res = await fetch(`${ROUTE}/api/boards/${boardName}`, {
|
|
518
|
+
method: 'PUT', headers: { 'content-type': 'application/json' },
|
|
519
|
+
body: JSON.stringify({ board, baseHash: boardHash }),
|
|
520
|
+
})
|
|
521
|
+
if (get().board !== boardName) return true // switched boards mid-flight; stale response must not touch state
|
|
522
|
+
if (res.status === 409) {
|
|
523
|
+
const reloaded = await get().boot() // hash advances only when the authoritative reload commits
|
|
524
|
+
if (reloaded) get().toast('board changed on disk - canvas layout reloaded')
|
|
525
|
+
return reloaded // false keeps dirty set; the next debounce retries once edits settle
|
|
526
|
+
}
|
|
527
|
+
if (res.ok) {
|
|
528
|
+
const { sha256 } = await res.json()
|
|
529
|
+
// an edit that landed after this save started keeps dirty set for the next save
|
|
530
|
+
set({ boardHash: sha256, ...(editRev === rev ? { dirty: false } : {}) })
|
|
531
|
+
return true
|
|
532
|
+
}
|
|
533
|
+
return false
|
|
534
|
+
} catch { return false /* dev server gone; edits stay local and dirty */ }
|
|
535
|
+
})
|
|
536
|
+
saveChain = p
|
|
537
|
+
return p
|
|
538
|
+
},
|
|
539
|
+
}
|
|
540
|
+
})
|