@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,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
|
+
}
|