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