@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,633 @@
1
+ import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { useStore, CONFIG, boardLabel, cap, fetchBoardNames } from './store.ts'
4
+ import { Tip } from './Tip.tsx'
5
+ import { ROUTE } from '../const.ts'
6
+ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
+ import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
+ import { bootHash, parseHash, writeHash } from './hash.ts'
9
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, deviceIcon } from './icons.tsx'
10
+
11
+ let booted = false // survives Fast Refresh; see the boot effect
12
+
13
+ /** One collapsible scene group in the sidebar. `held` marks a scene that contains a
14
+ * selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
15
+ function SceneGroup({ name, count, held, children }: { name: string; count: number; held: boolean; children: ReactNode }) {
16
+ const [open, setOpen] = useState(true)
17
+ return (
18
+ <div>
19
+ <button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
20
+ <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
21
+ <span>{cap(name) || '(root)'}</span>
22
+ <small>{count}</small>
23
+ </button>
24
+ {open && children}
25
+ </div>
26
+ )
27
+ }
28
+
29
+ /** A shell bug shows a banner, never a white screen. */
30
+ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
31
+ state = { err: null as Error | null }
32
+ static getDerivedStateFromError(err: Error) { return { err } }
33
+ render() {
34
+ if (!this.state.err) return this.props.children
35
+ return (
36
+ <div style={{ fontFamily: 'ui-monospace, monospace', padding: 32 }}>
37
+ <b style={{ color: '#a81f16' }}>shell crashed</b>
38
+ <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{this.state.err.message}</pre>
39
+ <button onClick={() => location.reload()}>reload</button>
40
+ </div>
41
+ )
42
+ }
43
+ }
44
+
45
+ /** Shared popover machinery: trigger position, outside-click close, portal to the app
46
+ * root (glass never nests - a nested backdrop-filter cannot sample the page). */
47
+ function usePopover() {
48
+ const [open, setOpen] = useState(false)
49
+ const [pos, setPos] = useState({ left: 0, top: 0 })
50
+ const boxRef = useRef<HTMLDivElement>(null)
51
+ const menuRef = useRef<HTMLDivElement>(null)
52
+ const toggle = () => {
53
+ if (!open && boxRef.current) {
54
+ const r = boxRef.current.getBoundingClientRect()
55
+ setPos({ left: r.left, top: r.bottom + 10 })
56
+ }
57
+ setOpen(!open)
58
+ }
59
+ useEffect(() => {
60
+ if (!open) return
61
+ const close = (e: PointerEvent) => {
62
+ const t = e.target as globalThis.Node
63
+ if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
64
+ }
65
+ window.addEventListener('pointerdown', close)
66
+ return () => window.removeEventListener('pointerdown', close)
67
+ }, [open])
68
+ return { open, setOpen, pos, boxRef, menuRef, toggle }
69
+ }
70
+
71
+ function Popover({ pop, children }: { pop: ReturnType<typeof usePopover>; children: ReactNode }) {
72
+ const app = document.querySelector('.sh-app')
73
+ if (!pop.open || !app) return null
74
+ return createPortal(
75
+ <div className="sh-menu" ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
76
+ app,
77
+ )
78
+ }
79
+
80
+ /** Boards live flat in the sidebar - always visible, one click to switch. The list
81
+ * refreshes on mount, window focus, and a slow poll so agent-created board files
82
+ * appear without a reload. Active board = accent icon + wash, same language as scenes. */
83
+ function BoardList() {
84
+ const board = useStore((s) => s.board)
85
+ const [names, setNames] = useState<string[]>(['all-scenes'])
86
+ useEffect(() => {
87
+ const refresh = () => fetchBoardNames().then(setNames).catch(() => { /* keep the last known list */ })
88
+ refresh()
89
+ const t = setInterval(refresh, 8000)
90
+ window.addEventListener('focus', refresh)
91
+ return () => { clearInterval(t); window.removeEventListener('focus', refresh) }
92
+ }, [])
93
+ const pick = async (name: string) => {
94
+ if (name === useStore.getState().board) return
95
+ await useStore.getState().switchBoard(name)
96
+ setTimeout(() => canvasCtl.fitAll(), 60)
97
+ }
98
+ return (
99
+ <>
100
+ {names.map((n) => (
101
+ <button key={n} className={`it board${n === board ? ' cur' : ''}`} onClick={() => pick(n)}>
102
+ {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
103
+ <span>{boardLabel(n)}</span>
104
+ </button>
105
+ ))}
106
+ </>
107
+ )
108
+ }
109
+
110
+ /** Selection toolbar: screen-space overlay above the selected frame - constant size at any
111
+ * zoom. Position derives from --sh-s/tx/ty (written per transform frame in Canvas), so
112
+ * pan/zoom tracking is pure CSS with zero React re-renders. */
113
+ function SelectionBar() {
114
+ const selection = useStore((s) => s.selection)
115
+ const node = useStore((s) => s.nodes.find((n) => n.key === s.selection[s.selection.length - 1]))
116
+ const frame = useStore((s) => (node ? s.frameFor(node) : undefined))
117
+ const nodes = useStore((s) => s.nodes)
118
+ if (!node || !frame || node.missing) return null
119
+ // anchor: centered over the bounding box of ALL selected frames, above the topmost
120
+ const selNodes = nodes.filter((n) => selection.includes(n.key))
121
+ const bx0 = Math.min(...selNodes.map((n) => n.x))
122
+ const bx1 = Math.max(...selNodes.map((n) => n.x + n.w))
123
+ const by0 = Math.min(...selNodes.map((n) => n.y))
124
+ const { resizeSelected, spawn, toast } = useStore.getState()
125
+ const multi = selection.length > 1
126
+ const applyDevice = (name: string) => {
127
+ animateLayout()
128
+ resizeSelected(name)
129
+ setTimeout(() => canvasCtl.fitNodes(useStore.getState().selection), 30)
130
+ }
131
+ const setNodeTheme = (t: string) => useStore.getState().setSelectedTheme(t)
132
+ const selectedFrames = () => {
133
+ const st = useStore.getState()
134
+ return st.selection
135
+ .map((k) => { const n = st.nodes.find((x) => x.key === k); return n ? st.frameFor(n) : undefined })
136
+ .filter((f): f is NonNullable<typeof f> => !!f)
137
+ }
138
+ return (
139
+ <div
140
+ className="sh-ctx"
141
+ style={{
142
+ // centered over the selection's bounding box; translateX keeps it centered at any width
143
+ left: `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`,
144
+ top: `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`,
145
+ transform: 'translateX(-50%)',
146
+ }}
147
+ >
148
+ {multi && <>
149
+ <Tip label={`${selection.length} frames selected`}><span className="cnt">{selection.length}</span></Tip>
150
+ <i className="sep" />
151
+ </>}
152
+ {Object.entries(CONFIG.viewports).map(([name, vp], vi) => {
153
+ const active = node.w === vp.width
154
+ return (
155
+ <Tip key={name} label={<><b>{cap(name)}</b><span>{vp.width} × {vp.height}</span><span className="k">{vi + 1}</span></>}>
156
+ <button className={active ? 'on' : 'icon'} onClick={() => applyDevice(name)}>
157
+ {deviceIcon(name, 15)}{active && <span>{cap(name)}</span>}
158
+ </button>
159
+ </Tip>
160
+ )
161
+ })}
162
+ <i className="sep" />
163
+ {CONFIG.themes.map((t) => (
164
+ <Tip key={t} label={`${cap(t)} theme`}>
165
+ <button className={`icon${node.theme === t ? ' on' : ''}`} onClick={() => setNodeTheme(t)}>
166
+ {t === 'dark' ? <MoonIcon size={15} /> : t === 'light' ? <SunIcon size={15} /> : t}
167
+ </button>
168
+ </Tip>
169
+ ))}
170
+ <i className="sep" />
171
+ <Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k">C</span></>}>
172
+ <button className="icon"
173
+ onClick={() => { navigator.clipboard.writeText(selectedFrames().map((f) => f.file).join('\n')); toast(multi ? `${selection.length} file paths copied` : 'file path copied') }}><SignpostIcon size={15} /></button>
174
+ </Tip>
175
+ <Tip label={multi ? 'Duplicate frames' : 'Duplicate frame'}>
176
+ <button className="icon" onClick={() => selectedFrames().forEach((f) => spawn(f.id))}><PlusIcon size={15} /></button>
177
+ </Tip>
178
+ </div>
179
+ )
180
+ }
181
+
182
+ /** Devices view: one click sizes frames to a device width, tidies, and fits. Scoped
183
+ * like the digit keys: the selection when one exists, the whole board otherwise. */
184
+ function DeviceMenu() {
185
+ const deviceView = useStore((s) => s.deviceView)
186
+ const selection = useStore((s) => s.selection)
187
+ const nodes = useStore((s) => s.nodes)
188
+ const pop = usePopover()
189
+ const scoped = selection.length > 0
190
+ const pick = (name: string | null) => {
191
+ animateLayout()
192
+ const st = useStore.getState()
193
+ if (scoped) {
194
+ st.resizeSelected(name)
195
+ setTimeout(() => canvasCtl.fitNodes(useStore.getState().selection), 30)
196
+ } else {
197
+ st.setDeviceView(name)
198
+ setTimeout(() => canvasCtl.fitAll(), 30)
199
+ }
200
+ pop.setOpen(false)
201
+ }
202
+ const entries = Object.entries(CONFIG.viewports)
203
+ // active check: board-wide it is the device view; scoped, the device every selected frame wears
204
+ const selNodes = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
205
+ const active = scoped
206
+ ? entries.find(([, vp]) => selNodes.length > 0 && selNodes.every((n) => n.w === vp.width))?.[0] ?? null
207
+ : deviceView
208
+ return (
209
+ <div className="sh-theme" ref={pop.boxRef}>
210
+ <Tip side="bottom" label={<><b>Device view</b><span>{scoped ? `${selection.length} selected` : deviceView ? `${cap(deviceView)} · 0 resets` : `keys 1-${entries.length}`}</span></>}>
211
+ <button className="sh-pill-btn" onClick={pop.toggle}>
212
+ {deviceIcon(active, 16)}
213
+ <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
214
+ </button>
215
+ </Tip>
216
+ <Popover pop={pop}>
217
+ <Tip side="bottom" label={scoped ? 'Selected frames at their default sizes' : 'Every frame at its own default size'}>
218
+ <button onClick={() => pick(null)}>
219
+ <DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
220
+ {!scoped && deviceView === null && <CheckIcon size={13} className="chk" />}
221
+ </button>
222
+ </Tip>
223
+ <i className="div" />
224
+ {entries.map(([name, vp], i) => (
225
+ <button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
226
+ {deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
227
+ {active === name && <CheckIcon size={13} className="chk" />}
228
+ </button>
229
+ ))}
230
+ </Popover>
231
+ </div>
232
+ )
233
+ }
234
+
235
+ const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
236
+
237
+ /** Zoom preset dropdown on the percentage readout. */
238
+ function ZoomMenu() {
239
+ const scale = useStore((s) => s.scale)
240
+ const pop = usePopover()
241
+ const go = (fn: () => void) => { fn(); pop.setOpen(false) }
242
+ return (
243
+ <div className="sh-theme" ref={pop.boxRef}>
244
+ <Tip side="bottom" label="Zoom presets">
245
+ <button className="sh-pill-btn pct" onClick={pop.toggle}>{Math.round(scale * 100)}%</button>
246
+ </Tip>
247
+ <Popover pop={pop}>
248
+ {ZOOMS.map((z) => (
249
+ <button key={z} onClick={() => go(() => canvasCtl.zoomTo(z))}>
250
+ <span>{z * 100}%</span>
251
+ {z === 1 && <kbd>⇧0</kbd>}
252
+ {Math.abs(scale - z) < 0.03 && <CheckIcon size={13} className="chk" />}
253
+ </button>
254
+ ))}
255
+ <i className="div" />
256
+ <button onClick={() => go(canvasCtl.fitAll)}><span>Fit all</span><kbd>⇧1</kbd></button>
257
+ <button onClick={() => go(() => { const k = useStore.getState().selection; if (k.length) canvasCtl.fitNodes(k) })}>
258
+ <span>Fit selection</span><kbd>⇧2</kbd>
259
+ </button>
260
+ </Popover>
261
+ </div>
262
+ )
263
+ }
264
+
265
+ /** Theme dropdown, scoped like the device digits: the selection when one exists, every
266
+ * frame otherwise. The trigger reflects the scope's MAJORITY (per-frame overrides never
267
+ * flip a board-level trigger - it reports the level it acts on, same rule as the shell).
268
+ * The menu is PORTALED out of the pill: an element with backdrop-filter is a backdrop root,
269
+ * so a nested backdrop-filter samples the pill's surface instead of the page - flat grey. */
270
+ function ThemeMenu() {
271
+ const nodes = useStore((s) => s.nodes)
272
+ const selection = useStore((s) => s.selection)
273
+ const viewTheme = useStore((s) => s.viewTheme)
274
+ const pop = usePopover()
275
+ const scoped = selection.length > 0
276
+ // scoped: the trigger + check reflect the selection; global: the VIEW preference
277
+ const scope = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
278
+ const uniform = scoped
279
+ ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null)
280
+ : viewTheme
281
+ const majority = scoped
282
+ ? (scope.length ? [...scope.reduce((m, n) => m.set(n.theme, (m.get(n.theme) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1])[0][0] : viewTheme)
283
+ : viewTheme
284
+ const pick = (t: string) => {
285
+ const st = useStore.getState()
286
+ scoped ? st.setSelectedTheme(t) : st.setTheme(t)
287
+ pop.setOpen(false)
288
+ }
289
+ return (
290
+ <div className="sh-theme" ref={pop.boxRef}>
291
+ <Tip side="bottom" label={<><b>Theme</b><span>{scoped ? `${selection.length} selected · D` : 'all frames · D'}</span></>}>
292
+ <button className="sh-pill-btn" onClick={pop.toggle}>
293
+ {majority === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
294
+ <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
295
+ </button>
296
+ </Tip>
297
+ <Popover pop={pop}>
298
+ {CONFIG.themes.map((t) => (
299
+ <button key={t} onClick={() => pick(t)}>
300
+ {t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
301
+ <span>{t}</span>
302
+ {uniform === t && <CheckIcon size={13} className="chk" />}
303
+ </button>
304
+ ))}
305
+ </Popover>
306
+ </div>
307
+ )
308
+ }
309
+
310
+ export function App() {
311
+ const { manifest, nodes, panelOpen, toasts, selection } = useStore()
312
+ const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
313
+ const [pillOpen, setPillOpen] = useState(true)
314
+
315
+ // boot honors the deep link (SPEC-M2 §3): board before load, play mode after it.
316
+ // Selection + camera intent are restored by the Canvas boot effect. The module-level
317
+ // guard makes boot single-shot: Fast Refresh re-runs mount effects on every App edit,
318
+ // and a re-boot would revert live state to the long-consumed deep link.
319
+ useEffect(() => {
320
+ if (booted) return
321
+ booted = true
322
+ if (bootHash.board && bootHash.board !== useStore.getState().board)
323
+ useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
324
+ boot().then((ok) => {
325
+ urlReady.current = true
326
+ if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
327
+ })
328
+ }, [])
329
+
330
+ // the URL is a projection of state: design views replace in place; entering play and
331
+ // every in-play navigation push, so browser back walks the flow
332
+ const playState = useStore((s) => s.play)
333
+ const prevPlay = useRef(playState)
334
+ const urlReady = useRef(false) // never clobber a deep link before boot restores it
335
+ useEffect(() => {
336
+ if (!urlReady.current) return
337
+ const s = useStore.getState()
338
+ if (playState) {
339
+ const push = !prevPlay.current || prevPlay.current.at !== playState.at
340
+ writeHash({ board: s.board, play: playState }, push)
341
+ } else {
342
+ writeHash({ board: s.board, n: s.selection })
343
+ }
344
+ prevPlay.current = playState
345
+ })
346
+
347
+ // browser back/forward: re-apply the URL as a whole - board first, then play or
348
+ // selection against the newly loaded state. writeHash skips identical hashes, so
349
+ // restores never echo back into history.
350
+ useEffect(() => {
351
+ const onPop = async () => {
352
+ const h = parseHash()
353
+ let s = useStore.getState()
354
+ if (h.board && h.board !== s.board) {
355
+ if (s.play) s.setPlay(null) // a stale overlay must never survive into another board
356
+ await s.switchBoard(h.board)
357
+ s = useStore.getState()
358
+ if (s.board !== h.board) return // switch failed; the projection effect will re-sync the URL
359
+ }
360
+ if (h.play) {
361
+ if (s.play) playCtl.sync(h.play)
362
+ else enterPlay(h.play)
363
+ } else {
364
+ if (s.play) s.setPlay(null)
365
+ const keys = (h.n ?? []).filter((k) => s.nodes.some((n) => n.key === k))
366
+ useStore.setState({ selection: keys })
367
+ setTimeout(() => (keys.length ? canvasCtl.fitNodes(keys) : canvasCtl.fitAll()), 60)
368
+ }
369
+ }
370
+ // hashchange too: pasting a link into the same tab or editing the URL bar changes
371
+ // the hash WITHOUT a popstate - ignoring it let the projection rewrite the URL back.
372
+ // Our own writeHash never fires either event (history API), so there is no echo;
373
+ // back/forward fires both, and the handler is idempotent under the double call.
374
+ window.addEventListener('popstate', onPop)
375
+ window.addEventListener('hashchange', onPop)
376
+ return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
377
+ }, [])
378
+
379
+ // page title follows the open board
380
+ const board = useStore((s) => s.board)
381
+ useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board])
382
+
383
+ // favicon follows the mode: blue pack in design mode, purple pack in interact.
384
+ // The links are rebuilt (not toggled) so the set stays deterministic; the .ico is
385
+ // design-blue only, so interact mode omits it and the browser takes the purple PNGs.
386
+ const interactingIcon = useStore((s) => s.interact !== null)
387
+ useEffect(() => {
388
+ const sfx = interactingIcon ? '-interactive' : ''
389
+ document.head.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach((l) => l.remove())
390
+ const add = (rel: string, href: string, attrs: Record<string, string> = {}) => {
391
+ const l = document.createElement('link')
392
+ l.rel = rel
393
+ l.href = href
394
+ for (const [k, v] of Object.entries(attrs)) l.setAttribute(k, v)
395
+ document.head.appendChild(l)
396
+ }
397
+ if (!interactingIcon) add('icon', `${ROUTE}/favicon/favicon.ico`, { sizes: '48x48' })
398
+ add('icon', `${ROUTE}/favicon/favicon-32x32${sfx}.png`, { type: 'image/png', sizes: '32x32' })
399
+ add('icon', `${ROUTE}/favicon/favicon-16x16${sfx}.png`, { type: 'image/png', sizes: '16x16' })
400
+ add('apple-touch-icon', `${ROUTE}/favicon/apple-touch-icon${sfx}.png`)
401
+ }, [interactingIcon])
402
+
403
+ // focus rings appear ONLY during real Tab navigation: the browser flips into keyboard
404
+ // modality on ANY keystroke, so a shortcut press painted the stock double ring on
405
+ // whatever was last clicked. Tab arms body.sh-kbd; any pointer use disarms it.
406
+ useEffect(() => {
407
+ const arm = (e: KeyboardEvent) => { if (e.key === 'Tab') document.body.classList.add('sh-kbd') }
408
+ const disarm = () => document.body.classList.remove('sh-kbd')
409
+ window.addEventListener('keydown', arm, true)
410
+ window.addEventListener('pointerdown', disarm, true)
411
+ return () => { window.removeEventListener('keydown', arm, true); window.removeEventListener('pointerdown', disarm, true) }
412
+ }, [])
413
+
414
+ // browser pinch-zoom is disabled inside the app: a pinch over the chrome (or anywhere
415
+ // off-canvas) was scaling the PAGE and wrecking the layout. Canvas zoom is unaffected
416
+ // (rzpp handles its own events), and keyboard cmd +/- is never intercepted.
417
+ useEffect(() => {
418
+ const block = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }
419
+ const blockGesture = (e: Event) => e.preventDefault()
420
+ window.addEventListener('wheel', block, { passive: false })
421
+ document.addEventListener('gesturestart', blockGesture)
422
+ return () => {
423
+ window.removeEventListener('wheel', block)
424
+ document.removeEventListener('gesturestart', blockGesture)
425
+ }
426
+ }, [])
427
+
428
+ // live updates from the dev server (source-served shell has import.meta.hot)
429
+ useEffect(() => {
430
+ if (!import.meta.hot) return
431
+ import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
432
+ // multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
433
+ // re-boots silently, keeping whatever selection survives; a dirty one keeps its
434
+ // edits and converges through the 409 path on its next save (disk wins, spec §8).
435
+ import.meta.hot.on('sh:board', (m: any) => {
436
+ const s = useStore.getState()
437
+ if (m?.name !== s.board || m?.sha256 === s.boardHash || s.dirty) return
438
+ const sel = s.selection
439
+ s.boot().then((ok) => {
440
+ if (!ok) return
441
+ const nodes = useStore.getState().nodes
442
+ useStore.setState({ selection: sel.filter((k) => nodes.some((n) => n.key === k)) })
443
+ })
444
+ })
445
+ }, [])
446
+
447
+ // frame -> shell messages (spec §6 protocol); source validated against known iframes
448
+ useEffect(() => {
449
+ const onMsg = (e: MessageEvent) => {
450
+ const data = e.data
451
+ if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
452
+ const iframes = [...document.querySelectorAll('iframe')]
453
+ const el = iframes.find((f) => f.contentWindow === e.source) as HTMLIFrameElement | undefined
454
+ if (!el) return
455
+ const nodeKey = el.closest('[data-node]')?.getAttribute('data-node')
456
+ if (!nodeKey) return
457
+ const s = useStore.getState()
458
+
459
+ if (data.type === 'sh:ready') {
460
+ s.setStatus(nodeKey, 'ready')
461
+ } else if (data.type === 'sh:error') {
462
+ s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
463
+ } else if (data.type === 'sh:exit-interact') {
464
+ if (s.interact === nodeKey) setInteract(null)
465
+ } else if (data.type === 'sh:go') {
466
+ const target = String(data.target ?? '')
467
+ const existing = s.nodes.find((n) => n.frame === target && !n.missing)
468
+ const node = existing ?? spawn(target)
469
+ if (!node) return toast(`unknown goto target "${target}"`)
470
+ select(node.key)
471
+ // a goto from inside an interacting frame CARRIES interact mode to the target -
472
+ // walking a flow must not eject you to design mode at every hop
473
+ if (s.interact === nodeKey) setInteract(node.key)
474
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
475
+ }
476
+ }
477
+ window.addEventListener('message', onMsg)
478
+ return () => window.removeEventListener('message', onMsg)
479
+ }, [])
480
+
481
+ // keyboard: t tidy · d theme · ⌘\ panel · Escape exit · shift+0/1/2 zoom
482
+ useEffect(() => {
483
+ const onKey = (e: KeyboardEvent) => {
484
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
485
+ const s = useStore.getState()
486
+ if (s.play) return // play mode owns the keyboard (Play.tsx)
487
+ if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); togglePanel(); return }
488
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setPillOpen((o) => !o); return }
489
+ if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); s.selectAll(); return }
490
+ if (e.metaKey || e.ctrlKey) return
491
+ if (e.key === 'Escape') s.interact ? setInteract(null) : select(null)
492
+ if (e.key === 'p') enterPlay()
493
+ if (e.key === 't') { animateLayout(); runTidy() }
494
+ if (e.key === 'c' && s.selection.length) {
495
+ const files = s.selection
496
+ .map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
497
+ .filter((f): f is string => !!f)
498
+ if (files.length) {
499
+ navigator.clipboard.writeText(files.join('\n'))
500
+ toast(files.length > 1 ? `${files.length} file paths copied` : 'file path copied')
501
+ }
502
+ }
503
+ if (e.key === 'd' && CONFIG.themes.length > 1) {
504
+ // scoped like the device digits: selection pins those frames; no selection cycles
505
+ // the global VIEW theme (sticky across boards - the user's preference)
506
+ if (s.selection.length) {
507
+ const scope = s.nodes.filter((n) => s.selection.includes(n.key))
508
+ const cur = scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : CONFIG.themes[0]
509
+ s.setSelectedTheme(CONFIG.themes[(CONFIG.themes.indexOf(cur) + 1) % CONFIG.themes.length])
510
+ } else {
511
+ s.setTheme(CONFIG.themes[(CONFIG.themes.indexOf(s.viewTheme) + 1) % CONFIG.themes.length])
512
+ }
513
+ }
514
+ if (e.shiftKey && e.code === 'Digit0') canvasCtl.zoom100()
515
+ if (e.shiftKey && e.code === 'Digit1') canvasCtl.fitAll()
516
+ if (e.shiftKey && e.code === 'Digit2' && s.selection.length) canvasCtl.fitNodes(s.selection)
517
+ // plain digits: 0 = default, 1..n = devices. Scoped to the selection when one
518
+ // exists, board-wide otherwise. Every path tidies - presets never scramble layout.
519
+ if (!e.shiftKey && /^Digit[0-9]$/.test(e.code)) {
520
+ const idx = Number(e.code.slice(5))
521
+ const names = Object.keys(CONFIG.viewports)
522
+ if (idx !== 0 && !names[idx - 1]) return
523
+ const name = idx === 0 ? null : names[idx - 1]
524
+ animateLayout()
525
+ if (s.selection.length) {
526
+ s.resizeSelected(name)
527
+ setTimeout(() => canvasCtl.fitNodes(useStore.getState().selection), 30)
528
+ } else {
529
+ s.setDeviceView(name)
530
+ setTimeout(() => canvasCtl.fitAll(), 30)
531
+ }
532
+ }
533
+ }
534
+ window.addEventListener('keydown', onKey)
535
+ return () => window.removeEventListener('keydown', onKey)
536
+ }, [])
537
+
538
+ // the sidebar reflects the ACTIVE BOARD: only scenes/frames with a node on this board
539
+ const onBoard = new Set(nodes.map((n) => n.frame))
540
+ const frames = (manifest?.frames ?? []).filter((f) => onBoard.has(f.id))
541
+ const scenes = [...new Set(frames.map((f) => f.scene))].sort()
542
+ .map((name) => ({ name, frames: frames.filter((f) => f.scene === name).length }))
543
+ // frame ids currently selected, for marking their parent scenes as `held`
544
+ const selFrames = new Set(nodes.filter((n) => selection.includes(n.key)).map((n) => n.frame))
545
+ // the shell follows the user's VIEW theme - per-frame pins never flip the chrome
546
+ const dark = useStore((s) => s.viewTheme) === 'dark'
547
+ // interact mode re-accents the ENTIRE shell purple - one token override class,
548
+ // everything derived from --accent follows (mark, sidebar, bars, handles, beams)
549
+ const interacting = useStore((s) => s.interact !== null)
550
+
551
+ // leaving interact returns keyboard focus to the shell: while a frame held focus its
552
+ // document swallowed every shortcut (the bridge only forwards Escape), so d/t/digits
553
+ // appeared dead after an interact session
554
+ const appRef = useRef<HTMLDivElement>(null)
555
+ useEffect(() => {
556
+ if (!interacting) appRef.current?.focus({ preventScroll: true })
557
+ }, [interacting])
558
+
559
+ return (
560
+ <div ref={appRef} tabIndex={-1} className={`sh-app${dark ? ' dark' : ''}${interacting ? ' interacting' : ''}`}>
561
+ <Canvas />
562
+ <SelectionBar />
563
+
564
+ {/* floating pill panel (no top bar - spec); panel and fab are both always
565
+ mounted so collapse/expand can crossfade-morph between them */}
566
+ <aside className={`sh-panel${panelOpen ? '' : ' closed'}`} aria-hidden={!panelOpen}>
567
+ <div className="sh-panel-top">
568
+ <ParallelogramDuoIcon size={21} className="mark" />
569
+ <span className="name">Marver</span>
570
+ <Tip side="bottom" label={<><b>Collapse panel</b><span>⌘\</span></>}><button className="sh-ibtn" onClick={togglePanel} tabIndex={panelOpen ? 0 : -1}><PanelFilledIcon size={17} /></button></Tip>
571
+ </div>
572
+ <div className="sh-panel-scroll">
573
+ <div className="hd">Boards</div>
574
+ <BoardList />
575
+ <div className="hd" style={{ marginTop: 10 }}>Scenes</div>
576
+ {scenes.map((sc) => (
577
+ <SceneGroup key={sc.name} name={sc.name} count={sc.frames}
578
+ held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}>
579
+ {frames.filter((f) => f.scene === sc.name).map((f) => {
580
+ const n = nodes.find((x) => x.frame === f.id && !x.missing) ?? nodes.find((x) => x.frame === f.id)
581
+ const on = !!n && selection.includes(n.key)
582
+ return (
583
+ <div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => {
584
+ if (!n) return
585
+ select(n.key, e.shiftKey)
586
+ if (!e.shiftKey) canvasCtl.fitNode(n.key)
587
+ }}>{cap(f.id.split('/').slice(1).join('/') || f.id)}</div>
588
+ )
589
+ })}
590
+ </SceneGroup>
591
+ ))}
592
+ {frames.length === 0 && <div className="sub dim">no frames yet - ask your agent<br />(design/AGENTS.md)</div>}
593
+ </div>
594
+ </aside>
595
+ <Tip side="bottom" label={<><b>Open panel</b><span>⌘\</span></>}>
596
+ <button className={`sh-fab${panelOpen ? ' hidden' : ''}`} onClick={togglePanel}
597
+ aria-hidden={panelOpen} tabIndex={panelOpen ? -1 : 0}><PanelHollowIcon size={18} /></button>
598
+ </Tip>
599
+
600
+ {/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
601
+ <nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
602
+ <DeviceMenu />
603
+ <ThemeMenu />
604
+ <i className="sep" />
605
+ <ZoomMenu />
606
+ <Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
607
+ <button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><GridIcon size={16} /></button>
608
+ </Tip>
609
+ <i className="sep" />
610
+ <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
611
+ <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
612
+ </Tip>
613
+ <Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
614
+ <button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
615
+ <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
616
+ </button>
617
+ </Tip>
618
+ </nav>
619
+ <Tip side="bottom" label={<><b>Open toolbar</b><span>⌘/</span></>}>
620
+ <button className={`sh-pill-fab${pillOpen ? ' hidden' : ''}`} onClick={() => setPillOpen(true)}
621
+ aria-hidden={pillOpen} tabIndex={pillOpen ? -1 : 0}><PanelHollowIcon size={18} style={{ transform: 'rotate(90deg)' }} /></button>
622
+ </Tip>
623
+
624
+ <PlayOverlay />
625
+
626
+ {CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled (design/config.ts → theme)</div>}
627
+
628
+ <div className="sh-toasts">
629
+ {toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
630
+ </div>
631
+ </div>
632
+ )
633
+ }