@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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +38 -0
  4. package/dist/build-D0GnIR4G.mjs +244 -0
  5. package/dist/cli.mjs +58 -0
  6. package/dist/config-DMBEpdEN.mjs +132 -0
  7. package/dist/dev-BK4x3PBr.mjs +76 -0
  8. package/dist/init-DcOy1krf.mjs +168 -0
  9. package/dist/plugin-DB5t2WUl.mjs +437 -0
  10. package/dist/serve-BPNmWeJx.mjs +200 -0
  11. package/package.json +59 -0
  12. package/src/client/const.ts +5 -0
  13. package/src/client/frame-host/bridge.js +37 -0
  14. package/src/client/frame-host/index.html +11 -0
  15. package/src/client/frame-host/main.tsx +73 -0
  16. package/src/client/frame-host/registry.ts +40 -0
  17. package/src/client/runtime/index.ts +8 -0
  18. package/src/client/shell/App.tsx +633 -0
  19. package/src/client/shell/Play.tsx +399 -0
  20. package/src/client/shell/Tip.tsx +43 -0
  21. package/src/client/shell/canvas/Canvas.tsx +285 -0
  22. package/src/client/shell/canvas/FrameNode.tsx +194 -0
  23. package/src/client/shell/favicon/android-chrome-192x192-interactive.png +0 -0
  24. package/src/client/shell/favicon/android-chrome-192x192.png +0 -0
  25. package/src/client/shell/favicon/android-chrome-512x512-interactive.png +0 -0
  26. package/src/client/shell/favicon/android-chrome-512x512.png +0 -0
  27. package/src/client/shell/favicon/apple-touch-icon-interactive.png +0 -0
  28. package/src/client/shell/favicon/apple-touch-icon.png +0 -0
  29. package/src/client/shell/favicon/favicon-16x16-interactive.png +0 -0
  30. package/src/client/shell/favicon/favicon-16x16.png +0 -0
  31. package/src/client/shell/favicon/favicon-32x32-interactive.png +0 -0
  32. package/src/client/shell/favicon/favicon-32x32.png +0 -0
  33. package/src/client/shell/favicon/favicon.ico +0 -0
  34. package/src/client/shell/favicon/site.webmanifest +11 -0
  35. package/src/client/shell/hash.ts +67 -0
  36. package/src/client/shell/icons.tsx +53 -0
  37. package/src/client/shell/index.html +17 -0
  38. package/src/client/shell/main.tsx +9 -0
  39. package/src/client/shell/store.ts +540 -0
  40. package/src/client/shell/styles.css +508 -0
  41. package/src/client/shell/tidy.ts +24 -0
  42. package/src/client/stage/index.html +11 -0
  43. package/src/client/stage/main.tsx +160 -0
  44. package/templates/AGENTS-embedded.md +76 -0
  45. package/templates/AGENTS-studio.md +76 -0
  46. package/templates/demo/_fixtures.ts +9 -0
  47. package/templates/demo/dark.tsx +17 -0
  48. package/templates/demo/form.tsx +51 -0
  49. package/templates/demo/welcome.tsx +21 -0
  50. package/templates/design-tsconfig.json +8 -0
  51. 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
+ })