@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,285 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { TransformWrapper, TransformComponent, type ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'
3
+ import { CONFIG, useStore } from '../store.ts'
4
+ import { bootHash } from '../hash.ts'
5
+ import { FrameNode, HEADER } from './FrameNode.tsx'
6
+
7
+ /**
8
+ * The world. rzpp owns pan/zoom; nodes are absolutely positioned children of #sh-world.
9
+ *
10
+ * Interaction model (Figma conventions):
11
+ * two-finger scroll = pan · ctrl/cmd+scroll or pinch = zoom · space+drag or middle-drag = pan
12
+ * dragging a frame moves the FRAME ONLY - `excluded` classes plus the store gesture
13
+ * flag keep rzpp's native listeners out of it (React stopPropagation fires too late:
14
+ * rzpp listens on its wrapper, below React's delegation root in the bubble path).
15
+ *
16
+ * Iframe laws: render order = store order (append-only, never sorted); will-change only during
17
+ * gestures (G-3); iframes lose pointer-events during any gesture (G-4).
18
+ */
19
+
20
+ /**
21
+ * Fit centers content in the space the chrome leaves free, measured live at fit time:
22
+ * the open sidebar (or its collapsed FAB) claims a left column, the pill row claims a
23
+ * top band, and the screen-space selection bar needs headroom above the fitted content.
24
+ * Measuring instead of hardcoding keeps every fit trigger (shift+1/2, device presets,
25
+ * board switch) correct whether the sidebar is open or collapsed.
26
+ */
27
+ const GAP = 32
28
+ const BAR = 56
29
+ function insets(el: HTMLElement) {
30
+ const c = el.getBoundingClientRect()
31
+ const rect = (q: string) => document.querySelector(q)?.getBoundingClientRect()
32
+ const side = rect('.sh-panel:not(.closed)') ?? rect('.sh-fab')
33
+ const pill = rect('.sh-pill')
34
+ return {
35
+ left: (side ? Math.max(0, side.right - c.left) : 0) + GAP,
36
+ right: GAP + 16,
37
+ top: (pill ? Math.max(0, pill.bottom - c.top) : 0) + 12 + BAR,
38
+ bottom: GAP + 16,
39
+ }
40
+ }
41
+
42
+ /**
43
+ * World-space dot grid (sense-of-scale, the Figma/tldraw recipe): the grid pans and
44
+ * zooms with the content, and its pitch re-quantizes in octaves so the on-screen
45
+ * spacing always lands in [14, 28)px - zooming out doubles the world spacing instead
46
+ * of dissolving into noise. Alpha fades across the octave so level switches don't pop.
47
+ * Painted via CSS vars on the wrapper - no React re-render per frame.
48
+ */
49
+ const GRID = 20
50
+ function paintGrid(positionX: number, positionY: number, scale: number) {
51
+ const el = document.querySelector('.sh-canvas') as HTMLElement | null
52
+ if (!el) return
53
+ let step = GRID * scale
54
+ while (step < 14) step *= 2
55
+ while (step >= 28) step /= 2
56
+ const t = (step - 14) / 14
57
+ el.style.setProperty('--grid-size', `${step}px ${step}px`)
58
+ el.style.setProperty('--grid-pos', `${positionX % step}px ${positionY % step}px`)
59
+ el.style.setProperty('--grid-alpha', (0.35 + 0.65 * Math.min(1, t)).toFixed(3))
60
+ // screen-space overlays (selection bar) position themselves from these - no re-render per frame
61
+ const app = document.querySelector('.sh-app') as HTMLElement | null
62
+ if (app) {
63
+ app.style.setProperty('--sh-s', String(scale))
64
+ app.style.setProperty('--sh-inv', String(1 / scale)) // world-units-per-screen-px, for zoom-invariant strokes
65
+ app.style.setProperty('--sh-tx', `${positionX}px`)
66
+ app.style.setProperty('--sh-ty', `${positionY}px`)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Preset transitions: device resizes and tidy moves are store mutations that commit in
72
+ * one React render - instant, jarring jumps. Arming this class right before the mutation
73
+ * lets the nodes ease to their new size/position on the same duration+curve the camera
74
+ * fit uses, so frames and viewport travel together. Drags stay direct (class absent).
75
+ */
76
+ let presetTimer = 0
77
+ export function animateLayout(ms = 360) {
78
+ const w = document.getElementById('sh-world')
79
+ if (!w) return
80
+ w.classList.add('sh-preset')
81
+ clearTimeout(presetTimer)
82
+ presetTimer = window.setTimeout(() => w.classList.remove('sh-preset'), ms)
83
+ }
84
+
85
+ export const canvasCtl = {
86
+ fitNode(_key: string) {},
87
+ fitNodes(_keys: string[]) {},
88
+ fitAll() {},
89
+ zoomTo(_scale: number) {},
90
+ zoom100() { canvasCtl.zoomTo(1) },
91
+ }
92
+
93
+ export function Canvas() {
94
+ const nodes = useStore((s) => s.nodes)
95
+ const gesture = useStore((s) => s.gesture)
96
+ const setScale = useStore((s) => s.setScale)
97
+ const ref = useRef<ReactZoomPanPinchContentRef>(null)
98
+
99
+ useEffect(() => {
100
+ const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
101
+ // focus=true (selection fits): center on the TRUE viewport center for a proper focus
102
+ // state, then clamp back into the chrome-free band only if the content would slide
103
+ // under the sidebar or pill. Small frames land dead center; wide selections degrade
104
+ // to band-centering. fitAll stays band-centered - there everything must be visible.
105
+ const fitRect = (x: number, y: number, w: number, h: number, focus = false) => {
106
+ const el = wrap()
107
+ if (!el || !ref.current) return
108
+ const p = insets(el)
109
+ const vw = el.clientWidth, vh = el.clientHeight
110
+ const aw = vw - p.left - p.right
111
+ const ah = vh - p.top - p.bottom
112
+ const scale = Math.max(0.05, Math.min(1, aw / w, ah / h))
113
+ let px = p.left + (aw - w * scale) / 2
114
+ let py = p.top + (ah - h * scale) / 2
115
+ if (focus) {
116
+ // clamp order matters: left/top win when the band is tighter than the content
117
+ px = Math.max(Math.min((vw - w * scale) / 2, vw - p.right - w * scale), p.left)
118
+ py = Math.max(Math.min((vh - h * scale) / 2, vh - p.bottom - h * scale), p.top)
119
+ }
120
+ ref.current.setTransform(px - x * scale, py - y * scale, scale, 320, 'easeOut')
121
+ }
122
+ canvasCtl.fitNode = (key: string) => {
123
+ const n = useStore.getState().nodes.find((x) => x.key === key)
124
+ if (n) fitRect(n.x, n.y, n.w, n.h + HEADER, true)
125
+ }
126
+ const fitKeys = (keys: string[], focus: boolean) => {
127
+ const sel = new Set(keys)
128
+ const ns = useStore.getState().nodes.filter((n) => sel.has(n.key))
129
+ if (!ns.length) return
130
+ const x0 = Math.min(...ns.map((n) => n.x)), y0 = Math.min(...ns.map((n) => n.y))
131
+ const x1 = Math.max(...ns.map((n) => n.x + n.w)), y1 = Math.max(...ns.map((n) => n.y + n.h + HEADER))
132
+ fitRect(x0, y0, x1 - x0, y1 - y0, focus)
133
+ }
134
+ canvasCtl.fitNodes = (keys: string[]) => fitKeys(keys, true)
135
+ canvasCtl.fitAll = () => fitKeys(useStore.getState().nodes.map((n) => n.key), false)
136
+ canvasCtl.zoomTo = (target: number) => {
137
+ const el = wrap(), inst = ref.current
138
+ if (!el || !inst) return
139
+ const { positionX, positionY, scale } = inst.instance.transformState
140
+ const cx = el.clientWidth / 2, cy = el.clientHeight / 2
141
+ const k = target / scale // zoom about the viewport center
142
+ inst.setTransform(cx - (cx - positionX) * k, cy - (cy - positionY) * k, target, 250, 'easeOut')
143
+ }
144
+ }, [])
145
+
146
+ // first load opens on the whole board (same as ⇧1) - the default 100% transform is an
147
+ // arbitrary top-left crop. A deep link with a selection (#/b/x?n=...) restores it and
148
+ // fits the camera to it instead. Runs once, on the first frame batch; board switches
149
+ // refit through their own path.
150
+ const booted = useRef(false)
151
+ useEffect(() => {
152
+ if (booted.current || nodes.length === 0) return
153
+ booted.current = true
154
+ const fit = () => {
155
+ const keys = (bootHash.n ?? []).filter((k) => useStore.getState().nodes.some((n) => n.key === k))
156
+ if (keys.length) {
157
+ useStore.setState({ selection: keys })
158
+ canvasCtl.fitNodes(keys)
159
+ } else canvasCtl.fitAll()
160
+ }
161
+ requestAnimationFrame(() => {
162
+ fit()
163
+ // published builds boot fast enough that rzpp's own init can land AFTER this fit
164
+ // and stomp it back to the identity transform - one verification pass re-fits
165
+ setTimeout(() => {
166
+ const st = ref.current?.instance.transformState
167
+ if (st && st.scale === 1 && st.positionX === 0 && st.positionY === 0) fit()
168
+ }, 150)
169
+ })
170
+ }, [nodes])
171
+
172
+ // click on empty canvas = deselect + exit interact (Figma convention; also covers the
173
+ // double-click-outside exit). Bound on the wrapper, NOT #sh-world - the world element is
174
+ // 1px by design, so empty-canvas clicks never hit it.
175
+ useEffect(() => {
176
+ const el = document.querySelector('.sh-canvas') as HTMLElement | null
177
+ if (!el) return
178
+ const down = (e: PointerEvent) => {
179
+ const t = e.target as HTMLElement | null
180
+ if (t?.closest('.sh-node')) return
181
+ const s = useStore.getState()
182
+ if (s.selection.length || s.interact) s.select(null)
183
+ }
184
+ el.addEventListener('pointerdown', down)
185
+ return () => el.removeEventListener('pointerdown', down)
186
+ }, [])
187
+
188
+ // Zoom curve: rzpp's wheel zoom is ADDITIVE in scale - a constant scale amount per
189
+ // pinch, which reads as sluggish when zoomed in and runaway when zoomed out (zoom is
190
+ // perceptually logarithmic). So ctrl/meta wheels (trackpad pinch arrives as ctrl-wheel
191
+ // in Chrome; cmd+scroll folds in too) are intercepted on an ANCESTOR of the wrapper -
192
+ // guaranteed to run before rzpp's own listener - and applied as an exponential step:
193
+ // constant ratio per finger distance, uniform feel at every zoom level.
194
+ useEffect(() => {
195
+ const app = document.querySelector('.sh-app') as HTMLElement | null
196
+ const el = document.querySelector('.sh-canvas') as HTMLElement | null
197
+ if (!app || !el) return
198
+ let settle = 0
199
+ const onWheel = (e: WheelEvent) => {
200
+ if (!e.ctrlKey && !e.metaKey) return
201
+ if (!(e.target as HTMLElement | null)?.closest?.('.sh-canvas')) return
202
+ e.preventDefault()
203
+ e.stopImmediatePropagation()
204
+ const inst = ref.current
205
+ if (!inst) return
206
+ const { positionX, positionY, scale } = inst.instance.transformState
207
+ // exponent scales with input; per-event ratio capped so a discrete mouse-wheel
208
+ // notch (|deltaY| ~100) stays controllable while trackpad streams pass through
209
+ const f = Math.min(1.4, Math.max(1 / 1.4, Math.exp(-e.deltaY * 0.0075 * (CONFIG.zoomSpeed ?? 1))))
210
+ const next = Math.min(2, Math.max(0.05, scale * f))
211
+ if (next === scale) return
212
+ const k = next / scale
213
+ const r = el.getBoundingClientRect()
214
+ const cx = e.clientX - r.left, cy = e.clientY - r.top
215
+ // rzpp's zoom callbacks never fire on setTransform - manage the gesture class here
216
+ document.getElementById('sh-world')?.classList.add('sh-gesturing')
217
+ clearTimeout(settle)
218
+ settle = window.setTimeout(() => document.getElementById('sh-world')?.classList.remove('sh-gesturing'), 160)
219
+ inst.setTransform(cx - (cx - positionX) * k, cy - (cy - positionY) * k, next, 0, 'linear')
220
+ }
221
+ app.addEventListener('wheel', onWheel, { capture: true, passive: false })
222
+ return () => { app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
223
+ }, [])
224
+
225
+ // space-pan: hold space, drag anywhere - nodes drop pointer-events so the canvas takes the drag
226
+ useEffect(() => {
227
+ const down = (e: KeyboardEvent) => {
228
+ if (e.code !== 'Space' || e.repeat) return
229
+ const t = e.target as HTMLElement
230
+ if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
231
+ e.preventDefault()
232
+ document.body.classList.add('sh-space')
233
+ }
234
+ const up = (e: KeyboardEvent) => { if (e.code === 'Space') document.body.classList.remove('sh-space') }
235
+ const drop = () => document.body.classList.remove('sh-space')
236
+ window.addEventListener('keydown', down)
237
+ window.addEventListener('keyup', up)
238
+ window.addEventListener('blur', drop)
239
+ return () => {
240
+ window.removeEventListener('keydown', down)
241
+ window.removeEventListener('keyup', up)
242
+ window.removeEventListener('blur', drop)
243
+ }
244
+ }, [])
245
+
246
+ const pan = (on: boolean) => () => {
247
+ document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
248
+ document.body.classList[on ? 'add' : 'remove']('sh-panning')
249
+ }
250
+ const zoom = (on: boolean) => () =>
251
+ document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
252
+
253
+ // no initialPosition props on the wrapper: rzpp applies them ASYNC after mount and
254
+ // stomps the boot fit when data is inlined (published builds) - the boot fit owns
255
+ // the first camera, and until it lands the default (0,0,1) is a known sentinel
256
+ return (
257
+ <TransformWrapper
258
+ ref={ref}
259
+ minScale={0.05}
260
+ maxScale={2}
261
+ limitToBounds={false}
262
+ doubleClick={{ disabled: true }}
263
+ // wheelDisabled is load-bearing: rzpp's onWheelPanning is a no-op without it, and
264
+ // ctrlKey wheels (pinch, ctrl/cmd+scroll via the rewrite above) still zoom
265
+ // wheelDisabled is load-bearing: rzpp's onWheelPanning is a no-op without it.
266
+ // ctrl/meta wheel zoom never reaches rzpp (exponential curve above); pinch.step
267
+ // only serves real touch-screen pinch.
268
+ wheel={{ wheelDisabled: true, step: 0.225 * (CONFIG.zoomSpeed ?? 1) }}
269
+ panning={{ wheelPanning: true, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
270
+ pinch={{ step: 7.5 * (CONFIG.zoomSpeed ?? 1) }}
271
+ onPanningStart={pan(true)}
272
+ onPanningStop={pan(false)}
273
+ onZoomStart={zoom(true)}
274
+ onZoomStop={zoom(false)}
275
+ onTransformed={(r) => { setScale(r.state.scale); paintGrid(r.state.positionX, r.state.positionY, r.state.scale) }}
276
+ onInit={(r) => paintGrid(r.state.positionX, r.state.positionY, r.state.scale)}
277
+ >
278
+ <TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">
279
+ <div id="sh-world">
280
+ {nodes.map((n) => <FrameNode key={n.key} node={n} />)}
281
+ </div>
282
+ </TransformComponent>
283
+ </TransformWrapper>
284
+ )
285
+ }
@@ -0,0 +1,194 @@
1
+ import { memo, useEffect, useRef } from 'react'
2
+ import { frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
+ import { CopyIcon, ReloadIcon, XIcon } from '../icons.tsx'
4
+
5
+ export const HEADER = 28
6
+ const SNAP = 12
7
+
8
+ /**
9
+ * One frame on the canvas. Iframe laws (spec §7): the iframe element is created once per node key
10
+ * and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
11
+ *
12
+ * Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
13
+ * TARGET's classList, nothing else), and drags additionally raise the store gesture flag,
14
+ * which hard-disables canvas panning for the duration. Both are needed: the class stops the
15
+ * pan before it starts, the flag covers targets we missed.
16
+ */
17
+ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
18
+ const frame = useStore((s) => s.frameFor(node))
19
+ const selected = useStore((s) => s.selection.includes(node.key))
20
+ const interact = useStore((s) => s.interact === node.key)
21
+ const scale = useStore((s) => s.scale)
22
+ const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
23
+ const iframeRef = useRef<HTMLIFrameElement>(null)
24
+ const themeRef = useRef(node.theme)
25
+ // src is frozen at mount: theme changes ride sh:set-theme (never navigation), so
26
+ // frame state (forms, scroll, dialogs) survives a theme flip. Real file changes below.
27
+ const initialSrc = useRef<string | null>(null)
28
+ if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
29
+ const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
30
+
31
+ // theme switch without remount
32
+ useEffect(() => {
33
+ if (themeRef.current !== node.theme) {
34
+ themeRef.current = node.theme
35
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
36
+ }
37
+ }, [node.theme])
38
+
39
+ // a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
40
+ useEffect(() => {
41
+ if (!frame) return
42
+ const sig = `${frame.kind}:${frame.file}`
43
+ if (fileRef.current !== null && fileRef.current !== sig && iframeRef.current) {
44
+ setStatus(node.key, 'loading')
45
+ iframeRef.current.src = frameUrl(frame, node.theme)
46
+ }
47
+ fileRef.current = sig
48
+ }, [frame?.kind, frame?.file])
49
+
50
+ // ready timeout (spec §7): 10s without sh:ready -> error card with reload
51
+ useEffect(() => {
52
+ if (node.status !== 'loading') return
53
+ const t = setTimeout(() => setStatus(node.key, 'error', 'frame never reported ready (10s)'), 10_000)
54
+ return () => clearTimeout(t)
55
+ }, [node.status, node.key, setStatus])
56
+
57
+ const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
58
+ e.stopPropagation()
59
+ if (e.button !== 0) return
60
+ // shift-click toggles multi-selection instead of dragging (Figma convention)
61
+ if (e.shiftKey && mode === 'move') { select(node.key, true); return }
62
+ if (!(selected && useStore.getState().selection.length > 1)) select(node.key)
63
+ const el = e.currentTarget as HTMLElement
64
+ const world = document.getElementById('sh-world')!
65
+ // Law G-5: measured scale, never stored zoom state. #sh-world is 1px wide by design,
66
+ // so its rendered rect width IS the scale (survives browser page-zoom too).
67
+ const gestureScale = world.getBoundingClientRect().width || scale || 1
68
+ const start = { x: e.clientX, y: e.clientY, nx: node.x, ny: node.y, nw: node.w, nh: node.h }
69
+ // group drag: moving any member moves the whole selection by the same delta
70
+ const st = useStore.getState()
71
+ const groupStarts: Record<string, { x: number; y: number }> = {}
72
+ if (mode === 'move' && st.selection.includes(node.key)) {
73
+ for (const k of st.selection) {
74
+ const n = st.nodes.find((x) => x.key === k)
75
+ if (n) groupStarts[k] = { x: n.x, y: n.y }
76
+ }
77
+ }
78
+ world.classList.add('sh-gesturing')
79
+ setGesture(true)
80
+
81
+ const onMove = (ev: PointerEvent) => {
82
+ const dx = (ev.clientX - start.x) / gestureScale
83
+ const dy = (ev.clientY - start.y) / gestureScale
84
+ if (mode === 'move') {
85
+ if (Object.keys(groupStarts).length > 1) moveSelectedBy(dx, dy, groupStarts)
86
+ else moveNode(node.key, start.nx + dx, start.ny + dy)
87
+ }
88
+ else {
89
+ let w = mode !== 's' ? start.nw + dx : start.nw
90
+ const h = mode !== 'e' ? start.nh + dy : start.nh
91
+ for (const vp of Object.values(CONFIG.viewports)) if (Math.abs(w - vp.width) < SNAP) w = vp.width
92
+ resizeNode(node.key, w, h)
93
+ }
94
+ }
95
+ // One idempotent teardown for every exit path - stuck gestures are the acceptance test.
96
+ let finished = false
97
+ const done = () => {
98
+ if (finished) return
99
+ finished = true
100
+ try { el.releasePointerCapture(e.pointerId) } catch { /* already released */ }
101
+ world.classList.remove('sh-gesturing')
102
+ setGesture(false)
103
+ el.removeEventListener('pointermove', onMove)
104
+ el.removeEventListener('pointerup', done)
105
+ el.removeEventListener('pointercancel', done)
106
+ el.removeEventListener('lostpointercapture', done)
107
+ window.removeEventListener('blur', done)
108
+ }
109
+ try { el.setPointerCapture(e.pointerId) } catch { setGesture(false); world.classList.remove('sh-gesturing'); return }
110
+ el.addEventListener('pointermove', onMove)
111
+ el.addEventListener('pointerup', done)
112
+ el.addEventListener('pointercancel', done)
113
+ el.addEventListener('lostpointercapture', done)
114
+ window.addEventListener('blur', done)
115
+ }
116
+
117
+ const gone = !frame || node.missing
118
+ // Spec §7: a deleted frame's node stays, with a card, until the user removes it. Explicit beats magic.
119
+ if (gone) {
120
+ return (
121
+ <div className={`sh-node${selected ? ' sel' : ''}`}
122
+ style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
123
+ data-node={node.key}>
124
+ <div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')}>
125
+ <span className="id sh-no-pan">{node.frame}</span><span className="dim sh-no-pan">deleted</span>
126
+ </div>
127
+ <div className="sh-node-body" style={{ height: node.h }}>
128
+ <div className="sh-card warn sh-no-pan">
129
+ <b>file deleted</b>
130
+ <span className="dim">{node.frame}</span>
131
+ <span className="row">
132
+ <button className="sh-no-pan" onClick={() => useStore.getState().removeNode(node.key)}>
133
+ <XIcon size={12} /> remove from board
134
+ </button>
135
+ </span>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ )
140
+ }
141
+
142
+ return (
143
+ <div
144
+ className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
145
+ data-theme={node.theme}
146
+ style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
147
+ data-node={node.key}
148
+ >
149
+ <div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
150
+ <span className="id sh-no-pan">{frame.title ?? frame.id}</span>
151
+ <span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
152
+ </div>
153
+
154
+ <div className="sh-node-body" style={{ height: node.h }}>
155
+ {node.status === 'error' ? (
156
+ <div className="sh-card err sh-no-pan">
157
+ <b>frame failed</b>
158
+ <span className="msg">{node.error}</span>
159
+ <span className="dim">{frame.file}</span>
160
+ <span className="row">
161
+ <button className="sh-no-pan" onClick={() => { setStatus(node.key, 'loading'); iframeRef.current && (iframeRef.current.src = frameUrl(frame, node.theme)) }}>
162
+ <ReloadIcon size={12} /> reload
163
+ </button>
164
+ <button className="sh-no-pan" onClick={() => { navigator.clipboard.writeText(`${frame.file}: ${node.error}`); toast('error copied for agent') }}>
165
+ <CopyIcon size={12} /> copy for agent
166
+ </button>
167
+ </span>
168
+ </div>
169
+ ) : null}
170
+ <iframe
171
+ ref={iframeRef}
172
+ src={initialSrc.current ?? frameUrl(frame, node.theme)}
173
+ title={frame.id}
174
+ style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
175
+ />
176
+ {!interact && (
177
+ <div
178
+ className="sh-overlay sh-no-pan"
179
+ onPointerDown={(e) => drag(e, 'move')}
180
+ onDoubleClick={(e) => { e.stopPropagation(); setInteract(node.key) }}
181
+ />
182
+ )}
183
+ </div>
184
+
185
+ {selected && (
186
+ <>
187
+ <div className="sh-handle e sh-no-pan" onPointerDown={(e) => drag(e, 'e')} />
188
+ <div className="sh-handle s sh-no-pan" onPointerDown={(e) => drag(e, 's')} />
189
+ <div className="sh-handle se sh-no-pan" onPointerDown={(e) => drag(e, 'se')} />
190
+ </>
191
+ )}
192
+ </div>
193
+ )
194
+ })
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "Marver",
3
+ "short_name": "Marver",
4
+ "icons": [
5
+ { "src": "/__mv/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
6
+ { "src": "/__mv/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
7
+ ],
8
+ "theme_color": "#0088ff",
9
+ "background_color": "#e7e9ef",
10
+ "display": "standalone"
11
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Deep links (SPEC-M2 §3). Hash-based so any static host serves them and a URL copied
3
+ * from dev works verbatim on a published site. The URL is a projection of state - the
4
+ * shell writes it as board/selection/play change and parses it once on boot; camera
5
+ * intent is always fit semantics (fit-all / fit-selection), never raw coordinates.
6
+ *
7
+ * #/ default board, fit all
8
+ * #/b/<board> board, fit all
9
+ * #/b/<board>?n=k1,k2 board with nodes selected, camera fit to selection
10
+ * #/p/<board>?at=<frame-id>&device=<viewport>&theme=<theme> play mode
11
+ */
12
+
13
+ export interface HashState {
14
+ board?: string
15
+ n?: string[]
16
+ play?: { at?: string; device?: string; theme?: string }
17
+ }
18
+
19
+ const BOARD_RE = /^[a-z0-9][a-z0-9-]*$/
20
+
21
+ export function parseHash(hash: string = location.hash): HashState {
22
+ try {
23
+ const raw = hash.replace(/^#/, '')
24
+ if (!raw || raw === '/') return {}
25
+ const q = raw.indexOf('?')
26
+ const path = q === -1 ? raw : raw.slice(0, q)
27
+ const params = new URLSearchParams(q === -1 ? '' : raw.slice(q + 1))
28
+ const m = path.match(/^\/(b|p)\/([^/?]+)$/)
29
+ if (!m) return {}
30
+ const board = decodeURIComponent(m[2])
31
+ if (!BOARD_RE.test(board)) return {}
32
+ if (m[1] === 'b') {
33
+ const n = (params.get('n') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
34
+ return { board, ...(n.length ? { n } : {}) }
35
+ }
36
+ return {
37
+ board,
38
+ play: {
39
+ at: params.get('at') ?? undefined,
40
+ device: params.get('device') ?? undefined,
41
+ theme: params.get('theme') ?? undefined,
42
+ },
43
+ }
44
+ } catch { return {} } // a malformed hash (#/b/%) is a default view, never a crash
45
+ }
46
+
47
+ export function buildHash(s: HashState): string {
48
+ if (s.play?.at && s.board) {
49
+ const p = new URLSearchParams({ at: s.play.at })
50
+ if (s.play.device) p.set('device', s.play.device)
51
+ if (s.play.theme) p.set('theme', s.play.theme)
52
+ return `#/p/${s.board}?${p}`
53
+ }
54
+ if (!s.board || (s.board === 'all-scenes' && !s.n?.length)) return '#/'
55
+ return `#/b/${s.board}${s.n?.length ? `?n=${s.n.join(',')}` : ''}`
56
+ }
57
+
58
+ /** Write the hash; identical URLs are skipped so restore paths never loop. */
59
+ export function writeHash(s: HashState, push = false) {
60
+ const h = buildHash(s)
61
+ if (h === location.hash || (h === '#/' && !location.hash)) return
62
+ if (push) history.pushState(null, '', h)
63
+ else history.replaceState(null, '', h)
64
+ }
65
+
66
+ /** The hash as it stood when the app loaded - boot intent, captured once. */
67
+ export const bootHash = parseHash()
@@ -0,0 +1,53 @@
1
+ import type { ReactNode, SVGProps } from 'react'
2
+
3
+ /**
4
+ * Phosphor icons (regular weight), path data inlined from @phosphor-icons/core
5
+ * (MIT (c) Phosphor Icons - phosphoricons.com). Deliberately NOT an icon package
6
+ * dependency: the shell ships as source into unknown hosts, and every runtime dep
7
+ * must resolve through the host's package-manager layout (see DECISIONS.md).
8
+ */
9
+ type IconProps = SVGProps<SVGSVGElement> & { size?: number }
10
+
11
+ const icon = (children: ReactNode) =>
12
+ function Icon({ size = 16, ...rest }: IconProps) {
13
+ return (
14
+ <svg width={size} height={size} viewBox="0 0 256 256" fill="currentColor" aria-hidden {...rest}>
15
+ {children}
16
+ </svg>
17
+ )
18
+ }
19
+
20
+ export const SunIcon = icon(<path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z" />)
21
+ export const MoonIcon = icon(<path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z" />)
22
+ export const CaretIcon = icon(<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z" />)
23
+ export const PlayIcon = icon(<path d="M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z" />)
24
+ export const GridIcon = icon(<path d="M104,40H56A16,16,0,0,0,40,56v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,104,40Zm0,64H56V56h48v48Zm96-64H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,64H152V56h48v48Zm-96,32H56a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,104,136Zm0,64H56V152h48v48Zm96-64H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,200,136Zm0,64H152V152h48v48Z" />)
25
+ export const CopyIcon = icon(<path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z" />)
26
+ export const PlusIcon = icon(<path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z" />)
27
+ export const XIcon = icon(<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />)
28
+ export const ReloadIcon = icon(<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z" />)
29
+ export const CheckIcon = icon(<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />)
30
+ export const DevicesIcon = icon(<path d="M224,72H208V64a24,24,0,0,0-24-24H40A24,24,0,0,0,16,64v96a24,24,0,0,0,24,24H152v8a24,24,0,0,0,24,24h48a24,24,0,0,0,24-24V96A24,24,0,0,0,224,72ZM40,168a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8v8H176a24,24,0,0,0-24,24v72Zm192,24a8,8,0,0,1-8,8H176a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8Zm-96,16a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h40A8,8,0,0,1,136,208Zm80-96a8,8,0,0,1-8,8H192a8,8,0,0,1,0-16h16A8,8,0,0,1,216,112Z" />)
31
+ export const DeviceMobileIcon = icon(<path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z" />)
32
+ export const DeviceTabletIcon = icon(<path d="M192,24H64A24,24,0,0,0,40,48V208a24,24,0,0,0,24,24H192a24,24,0,0,0,24-24V48A24,24,0,0,0,192,24ZM56,72H200V184H56Zm8-32H192a8,8,0,0,1,8,8v8H56V48A8,8,0,0,1,64,40ZM192,216H64a8,8,0,0,1-8-8v-8H200v8A8,8,0,0,1,192,216Z" />)
33
+ export const LaptopIcon = icon(<path d="M232,168h-8V72a24,24,0,0,0-24-24H56A24,24,0,0,0,32,72v96H24a8,8,0,0,0-8,8v16a24,24,0,0,0,24,24H216a24,24,0,0,0,24-24V176A8,8,0,0,0,232,168ZM48,72a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8v96H48ZM224,192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8v-8H224ZM152,88a8,8,0,0,1-8,8H112a8,8,0,0,1,0-16h32A8,8,0,0,1,152,88Z" />)
34
+ export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Z" />)
35
+ export const TvIcon = icon(<path d="M216,64H147.31l34.35-34.34a8,8,0,1,0-11.32-11.32L128,60.69,85.66,18.34A8,8,0,0,0,74.34,29.66L108.69,64H40A16,16,0,0,0,24,80V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,136H40V80H216V200Z" />)
36
+ export const SignpostIcon = icon(<path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z" />)
37
+ export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1" /><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z" /></>)
38
+ /** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
39
+ * pill filled while the sidebar is open, hollow while collapsed. */
40
+ export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" /></>)
41
+ export const PanelHollowIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" fill="none" stroke="currentColor" strokeWidth="14" /></>)
42
+ /** Board glyphs: CardsThree = the built-in `all-scenes` board; Cards = curated boards. */
43
+ export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V104A16,16,0,0,0,208,88Zm0,112H48V104H208v96ZM48,64a8,8,0,0,1,8-8H200a8,8,0,0,1,0,16H56A8,8,0,0,1,48,64ZM64,32a8,8,0,0,1,8-8H184a8,8,0,0,1,0,16H72A8,8,0,0,1,64,32Z" />)
44
+ export const FrameCornersIcon = icon(<path d="M160 48h40a8 8 0 0 1 8 8v40M208 160v40a8 8 0 0 1-8 8h-40M96 208H56a8 8 0 0 1-8-8v-40M48 96V56a8 8 0 0 1 8-8h40" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" />)
45
+ export const ArrowLeftIcon = icon(<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />)
46
+ export const ArrowRightIcon = icon(<path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z" />)
47
+ export const CardsIcon = icon(<path d="M184,72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V88A16,16,0,0,0,184,72Zm0,128H40V88H184V200ZM232,56V176a8,8,0,0,1-16,0V56H64a8,8,0,0,1,0-16H216A16,16,0,0,1,232,56Z" />)
48
+ /** Viewport-name -> device glyph; unknown custom names fall back to the generic Devices. */
49
+ export function deviceIcon(name: string | null, size = 14) {
50
+ const Icon = ({ mobile: DeviceMobileIcon, tablet: DeviceTabletIcon, laptop: LaptopIcon, monitor: MonitorIcon, tv: TvIcon } as Record<string, typeof DevicesIcon>)[name ?? ''] ?? DevicesIcon
51
+ return <Icon size={size} />
52
+ }
53
+