@marver-design/marver 0.10.0 → 0.10.2
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/CHANGELOG.md +56 -0
- package/README.md +2 -2
- package/dist/{build-Dd91jjIg.mjs → build-zUf_kwKp.mjs} +1 -1
- package/dist/cli.mjs +3 -3
- package/dist/{daemon-B_8BmmSJ.mjs → daemon-BpKBJZee.mjs} +1 -1
- package/dist/{dev-Dv0GlYQq.mjs → dev-D2PG6igI.mjs} +12 -2
- package/dist/{init-D2EQEbV6.mjs → init-DwV0_d9G.mjs} +7 -28
- package/dist/{ledger-BgA7nQoH.mjs → ledger-Bu0BjqIe.mjs} +1 -12
- package/dist/managed-HwHVNI3h.mjs +94 -0
- package/dist/{plugin-CgLVYTR-.mjs → plugin-C1sDewZ3.mjs} +107 -6
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/{shot-BRgPaFCs.mjs → shot-D7I0MCLu.mjs} +139 -12
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +6 -0
- package/src/client/frame-host/bridge.js +3 -1
- package/src/client/frame-host/main.tsx +4 -1
- package/src/client/shell/App.tsx +276 -31
- package/src/client/shell/Play.tsx +5 -4
- package/src/client/shell/canvas/FrameNode.tsx +45 -6
- package/src/client/shell/canvas/ready-watch.ts +28 -0
- package/src/client/shell/store.ts +83 -8
- package/src/client/shell/styles.css +33 -0
- package/templates/AGENTS-embedded.md +11 -4
- package/templates/AGENTS-studio.md +11 -4
- package/templates/instructions/boards.md +3 -1
- package/templates/instructions/jam.md +9 -0
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { useStore, CONFIG, PUBLISHED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
3
4
|
import { Tip } from './Tip.tsx'
|
|
4
5
|
import { PKG, ROUTE } from '../const.ts'
|
|
@@ -6,7 +7,7 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
|
6
7
|
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
7
8
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
8
9
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon,
|
|
10
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
11
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
11
12
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
12
13
|
import { useComments } from './comments-store.ts'
|
|
@@ -16,13 +17,64 @@ const commentsStore = () => useComments.getState()
|
|
|
16
17
|
|
|
17
18
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
18
19
|
|
|
20
|
+
/** Copy text to the clipboard, toasting the outcome. Success is confirmed out loud; a
|
|
21
|
+
* blocked clipboard (no user gesture / permission) says so instead of failing silently. */
|
|
22
|
+
function copyToClipboard(text: string, okMsg: string) {
|
|
23
|
+
const { toast } = useStore.getState()
|
|
24
|
+
navigator.clipboard.writeText(text).then(() => toast(okMsg), () => toast('copy blocked - click the canvas first'))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The address a frame copies - the same string from the sidebar right-click, the floating
|
|
28
|
+
* toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
|
|
29
|
+
const framePath = (board: string, f: { id: string; file: string }) => `board: ${board} · frame: ${f.id} (${f.file})`
|
|
30
|
+
|
|
31
|
+
type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
|
|
32
|
+
type MenuState = { x: number; y: number; items: MenuItem[] }
|
|
33
|
+
|
|
34
|
+
/** A right-click menu for the sidebar. One instance lives in App; `open(e, items)` positions
|
|
35
|
+
* it at the cursor, CLAMPED into the viewport. It closes on outside pointerdown, on pick, and
|
|
36
|
+
* on Escape - the Escape listener runs in CAPTURE phase so it does not also trip App's global
|
|
37
|
+
* keydown (which would clear the selection/laser underneath the menu). */
|
|
38
|
+
function useContextMenu() {
|
|
39
|
+
const [menu, setMenu] = useState<MenuState | null>(null)
|
|
40
|
+
const open = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => {
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
const MENU_W = 184
|
|
43
|
+
const h = items.length * 32 + 12
|
|
44
|
+
setMenu({ x: Math.min(e.clientX, window.innerWidth - MENU_W - 8), y: Math.min(e.clientY, window.innerHeight - h - 8), items })
|
|
45
|
+
}
|
|
46
|
+
return { menu, open, close: () => setMenu(null) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
|
|
50
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!menu) return
|
|
53
|
+
const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
|
|
54
|
+
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close() } }
|
|
55
|
+
window.addEventListener('pointerdown', onDown)
|
|
56
|
+
window.addEventListener('keydown', onEsc, true) // capture: beat App's bubble-phase Escape
|
|
57
|
+
return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('keydown', onEsc, true) }
|
|
58
|
+
}, [menu])
|
|
59
|
+
const app = document.querySelector('.sh-app')
|
|
60
|
+
if (!menu || !app) return null
|
|
61
|
+
return createPortal(
|
|
62
|
+
<div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
|
|
63
|
+
{menu.items.map((it) => (
|
|
64
|
+
<button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
|
|
65
|
+
))}
|
|
66
|
+
</div>,
|
|
67
|
+
app,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
19
71
|
/** One collapsible scene group in the sidebar. `held` marks a scene that contains a
|
|
20
72
|
* selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
|
|
21
|
-
function SceneGroup({ name, count, held, onPick, children }: { name: string; count: number; held: boolean; onPick?: () => void; children: ReactNode }) {
|
|
73
|
+
function SceneGroup({ name, count, held, onPick, onContextMenu, children }: { name: string; count: number; held: boolean; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
|
|
22
74
|
const [open, setOpen] = useState(true)
|
|
23
75
|
return (
|
|
24
76
|
<div>
|
|
25
|
-
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
|
|
77
|
+
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)} onContextMenu={onContextMenu}>
|
|
26
78
|
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
27
79
|
{/* the NAME selects every frame in the scene; the caret/row still collapses */}
|
|
28
80
|
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{humanize(name) || '(root)'}</span>
|
|
@@ -52,29 +104,187 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
|
|
|
52
104
|
/** Boards live flat in the sidebar - always visible, one click to switch. The list
|
|
53
105
|
* refreshes on mount, window focus, and a slow poll so agent-created board files
|
|
54
106
|
* appear without a reload. Active board = accent icon + wash, same language as scenes. */
|
|
55
|
-
|
|
107
|
+
const BOARD_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
|
|
108
|
+
|
|
109
|
+
function BoardList({ onMenu }: { onMenu: (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void }) {
|
|
56
110
|
const board = useStore((s) => s.board)
|
|
57
111
|
const [names, setNames] = useState<string[]>(['all-scenes'])
|
|
112
|
+
const [editing, setEditing] = useState<string | null>(null)
|
|
113
|
+
const [drag, setDrag] = useState<string | null>(null) // board being dragged
|
|
114
|
+
// insertion index in the CURATED list (0..len): one value per gap, so the divider lands
|
|
115
|
+
// once between two boards - not twice (A's bottom-half and B's top-half were separate spots)
|
|
116
|
+
const [dropIndex, setDropIndex] = useState<number | null>(null)
|
|
117
|
+
// genRef invalidates every async setNames: a mutation bumps it, so a poll/fetch that
|
|
118
|
+
// STARTED earlier can never clobber a fresh optimistic reorder or a just-renamed list.
|
|
119
|
+
const genRef = useRef(0)
|
|
120
|
+
const busyRef = useRef(0) // reorders in flight; while >0 polls hold off
|
|
121
|
+
const commitBusy = useRef(false) // guards Enter+blur firing two renames
|
|
122
|
+
const chainRef = useRef<Promise<unknown>>(Promise.resolve()) // serializes reorder POSTs
|
|
123
|
+
// reorder runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
|
|
124
|
+
// the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
|
|
125
|
+
// the grabbing cursor for the whole drag via a body class.
|
|
126
|
+
const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; name: string; dragging: boolean; el: HTMLElement } | null>(null)
|
|
127
|
+
const refresh = () => {
|
|
128
|
+
if (busyRef.current > 0) return // a reorder is mid-flight; its optimistic order stands until it settles
|
|
129
|
+
const gen = genRef.current
|
|
130
|
+
fetchBoardNames().then((list) => { if (gen === genRef.current) setNames(list) }).catch(() => { /* keep last known */ })
|
|
131
|
+
}
|
|
58
132
|
useEffect(() => {
|
|
59
|
-
const refresh = () => fetchBoardNames().then(setNames).catch(() => { /* keep the last known list */ })
|
|
60
133
|
refresh()
|
|
61
134
|
const t = setInterval(refresh, 8000)
|
|
62
135
|
window.addEventListener('focus', refresh)
|
|
63
136
|
return () => { clearInterval(t); window.removeEventListener('focus', refresh) }
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
64
138
|
}, [])
|
|
65
139
|
const pick = async (name: string) => {
|
|
66
140
|
if (name === useStore.getState().board) return
|
|
67
141
|
await useStore.getState().switchBoard(name)
|
|
68
142
|
setTimeout(() => canvasCtl.fitAll(), 60)
|
|
69
143
|
}
|
|
144
|
+
const commit = async (old: string, raw: string) => {
|
|
145
|
+
if (commitBusy.current) return // Enter already fired this; ignore the follow-up blur
|
|
146
|
+
const next = raw.trim()
|
|
147
|
+
if (!next || next === old) { setEditing(null); return }
|
|
148
|
+
if (!BOARD_NAME_RE.test(next) || next.length > 64 || next === 'all-scenes' || names.includes(next)) {
|
|
149
|
+
useStore.getState().toast('use a free name - lowercase letters, numbers and dashes'); return // stay editing
|
|
150
|
+
}
|
|
151
|
+
commitBusy.current = true
|
|
152
|
+
try {
|
|
153
|
+
const r = await useStore.getState().renameBoard(old, next)
|
|
154
|
+
if (r.ok) { setEditing(null); genRef.current++; refresh() }
|
|
155
|
+
else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
|
|
156
|
+
} finally { commitBusy.current = false }
|
|
157
|
+
}
|
|
158
|
+
// the INSERTION INDEX (in the curated list, 0..len) nearest the pointer - one value per
|
|
159
|
+
// gap, so a single divider lands between two boards instead of one per row-half
|
|
160
|
+
const indexAt = (x: number, y: number): number | null => {
|
|
161
|
+
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row]') as HTMLElement | null
|
|
162
|
+
const name = el?.dataset.board
|
|
163
|
+
if (!name) return null
|
|
164
|
+
const curated = names.filter((n) => n !== 'all-scenes')
|
|
165
|
+
if (name === 'all-scenes') return curated.length // over the pinned last row = end slot
|
|
166
|
+
const ci = curated.indexOf(name)
|
|
167
|
+
if (ci < 0) return null
|
|
168
|
+
const r = el!.getBoundingClientRect()
|
|
169
|
+
return y > r.top + r.height / 2 ? ci + 1 : ci
|
|
170
|
+
}
|
|
171
|
+
const resetPointer = () => {
|
|
172
|
+
const g = gestureRef.current
|
|
173
|
+
gestureRef.current = null
|
|
174
|
+
if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
|
|
175
|
+
document.body.classList.remove('sh-board-dragging')
|
|
176
|
+
setDrag(null)
|
|
177
|
+
setDropIndex(null)
|
|
178
|
+
}
|
|
179
|
+
// move the dragged board to the insertion index and persist the new order optimistically
|
|
180
|
+
const applyReorder = (dragName: string, dropIndex: number) => {
|
|
181
|
+
if (dragName === 'all-scenes') return
|
|
182
|
+
const curated = names.filter((n) => n !== 'all-scenes')
|
|
183
|
+
const from = curated.indexOf(dragName)
|
|
184
|
+
if (from < 0) return
|
|
185
|
+
const to = dropIndex > from ? dropIndex - 1 : dropIndex // removing `from` shifts later indices left
|
|
186
|
+
if (to === from) return // dropped in its own slot - no move
|
|
187
|
+
curated.splice(from, 1)
|
|
188
|
+
curated.splice(to, 0, dragName)
|
|
189
|
+
const prev = names
|
|
190
|
+
genRef.current++
|
|
191
|
+
const gen = genRef.current
|
|
192
|
+
busyRef.current++ // hold polls off until this settles
|
|
193
|
+
setNames(names.includes('all-scenes') ? [...curated, 'all-scenes'] : curated)
|
|
194
|
+
chainRef.current = chainRef.current.then(async () => {
|
|
195
|
+
try {
|
|
196
|
+
const ok = await useStore.getState().reorderBoards(curated)
|
|
197
|
+
if (gen !== genRef.current) return // a newer drop superseded this one
|
|
198
|
+
if (!ok) { setNames(prev); useStore.getState().toast('could not save order') }
|
|
199
|
+
} catch { if (gen === genRef.current) { setNames(prev); useStore.getState().toast('could not save order') } }
|
|
200
|
+
finally { busyRef.current-- }
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
const onBoardPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
|
|
204
|
+
if (e.button !== 0) return // left button only; right-click opens the menu
|
|
205
|
+
const el = e.currentTarget
|
|
206
|
+
try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
|
|
207
|
+
gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, name: n, dragging: false, el }
|
|
208
|
+
}
|
|
209
|
+
const onBoardPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
210
|
+
const g = gestureRef.current
|
|
211
|
+
if (!g || g.pointerId !== e.pointerId) return
|
|
212
|
+
if (!g.dragging) {
|
|
213
|
+
if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
|
|
214
|
+
g.dragging = true
|
|
215
|
+
setDrag(g.name)
|
|
216
|
+
document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
|
|
217
|
+
}
|
|
218
|
+
const idx = indexAt(e.clientX, e.clientY)
|
|
219
|
+
// hide the seam at the dragged board's OWN slot (index from or from+1 = no move)
|
|
220
|
+
const from = names.filter((nm) => nm !== 'all-scenes').indexOf(g.name)
|
|
221
|
+
setDropIndex(idx === null || idx === from || idx === from + 1 ? null : idx)
|
|
222
|
+
}
|
|
223
|
+
const onBoardPointerUp = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
|
|
224
|
+
const g = gestureRef.current
|
|
225
|
+
if (!g || g.pointerId !== e.pointerId) return
|
|
226
|
+
const dragged = g.dragging
|
|
227
|
+
const idx = dragged ? indexAt(e.clientX, e.clientY) : null
|
|
228
|
+
resetPointer()
|
|
229
|
+
if (dragged) { if (idx !== null) applyReorder(n, idx) }
|
|
230
|
+
else void pick(n) // a tap switches boards (the trailing mouse click is ignored)
|
|
231
|
+
}
|
|
232
|
+
// cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
|
|
235
|
+
const onBlur = () => { if (gestureRef.current) resetPointer() }
|
|
236
|
+
window.addEventListener('keydown', onEsc, true)
|
|
237
|
+
window.addEventListener('blur', onBlur)
|
|
238
|
+
return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
|
|
239
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
}, [])
|
|
241
|
+
const menuFor = (n: string): MenuItem[] => [
|
|
242
|
+
{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') },
|
|
243
|
+
...(!PUBLISHED && n !== 'all-scenes' ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setEditing(n) }] : []),
|
|
244
|
+
]
|
|
245
|
+
const curated = names.filter((nm) => nm !== 'all-scenes')
|
|
70
246
|
return (
|
|
71
247
|
<>
|
|
72
|
-
{names.map((n) =>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
248
|
+
{names.map((n) => {
|
|
249
|
+
if (editing === n) return (
|
|
250
|
+
<div key={n} className="it board editing">
|
|
251
|
+
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
252
|
+
<input autoFocus defaultValue={n} spellCheck={false}
|
|
253
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
254
|
+
onKeyDown={(e) => {
|
|
255
|
+
if (e.key === 'Enter') { e.preventDefault(); void commit(n, e.currentTarget.value) }
|
|
256
|
+
else if (e.key === 'Escape') { e.preventDefault(); setEditing(null) }
|
|
257
|
+
}}
|
|
258
|
+
onBlur={(e) => { if (editing === n) void commit(n, e.currentTarget.value) }} />
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
const canDrag = !PUBLISHED && n !== 'all-scenes'
|
|
262
|
+
// ONE seam per gap, from the insertion index: drop-before the row AT that index, or
|
|
263
|
+
// drop-after the last curated row when the index is the end. Overlay (::after), so no
|
|
264
|
+
// layout shift. Never on the row being dragged.
|
|
265
|
+
const ci = curated.indexOf(n)
|
|
266
|
+
const dropCls = drag == null || dropIndex == null || n === drag ? ''
|
|
267
|
+
: ci === dropIndex ? ' drop-before'
|
|
268
|
+
: dropIndex === curated.length && ci === curated.length - 1 ? ' drop-after'
|
|
269
|
+
: ''
|
|
270
|
+
return (
|
|
271
|
+
<button key={n} data-board-row data-board={n} data-reorderable={canDrag || undefined}
|
|
272
|
+
className={`it board${n === board ? ' cur' : ''}${drag === n ? ' dragging' : ''}${dropCls}`}
|
|
273
|
+
// draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
|
|
274
|
+
// click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
|
|
275
|
+
// (detail === 0) and non-draggable rows (all-scenes) switch here as normal
|
|
276
|
+
onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
|
|
277
|
+
onContextMenu={(e) => onMenu(e, menuFor(n))}
|
|
278
|
+
onPointerDown={canDrag ? (e) => onBoardPointerDown(e, n) : undefined}
|
|
279
|
+
onPointerMove={canDrag ? onBoardPointerMove : undefined}
|
|
280
|
+
onPointerUp={canDrag ? (e) => onBoardPointerUp(e, n) : undefined}
|
|
281
|
+
onPointerCancel={canDrag ? () => resetPointer() : undefined}
|
|
282
|
+
onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
283
|
+
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
284
|
+
<span>{boardLabel(n)}</span>
|
|
285
|
+
</button>
|
|
286
|
+
)
|
|
287
|
+
})}
|
|
78
288
|
</>
|
|
79
289
|
)
|
|
80
290
|
}
|
|
@@ -99,13 +309,24 @@ function SelectionBar() {
|
|
|
99
309
|
roRef.current.observe(el)
|
|
100
310
|
}
|
|
101
311
|
}
|
|
312
|
+
// the copy-path icon flashes into a check on a successful copy - from the icon OR the
|
|
313
|
+
// Shift+P shortcut. pathPulse (store) bumps on each copy; these hooks sit ABOVE the
|
|
314
|
+
// early return so their order never changes with the selection.
|
|
315
|
+
const pathPulse = useStore((s) => s.pathPulse)
|
|
316
|
+
const [copied, setCopied] = useState(false)
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!pathPulse) return
|
|
319
|
+
setCopied(true)
|
|
320
|
+
const t = setTimeout(() => setCopied(false), 1400)
|
|
321
|
+
return () => clearTimeout(t)
|
|
322
|
+
}, [pathPulse])
|
|
102
323
|
if (!node || !frame || node.missing) return null
|
|
103
324
|
// anchor: centered over the bounding box of ALL selected frames, above the topmost
|
|
104
325
|
const selNodes = nodes.filter((n) => selection.includes(n.key))
|
|
105
326
|
const bx0 = Math.min(...selNodes.map((n) => n.x))
|
|
106
327
|
const bx1 = Math.max(...selNodes.map((n) => n.x + n.w))
|
|
107
328
|
const by0 = Math.min(...selNodes.map((n) => n.y))
|
|
108
|
-
const { resizeSelected,
|
|
329
|
+
const { resizeSelected, toast } = useStore.getState()
|
|
109
330
|
const multi = selection.length > 1
|
|
110
331
|
const applyDevice = (name: string) => {
|
|
111
332
|
animateLayout()
|
|
@@ -167,12 +388,15 @@ function SelectionBar() {
|
|
|
167
388
|
</Tip>
|
|
168
389
|
))}
|
|
169
390
|
<i className="sep" />
|
|
170
|
-
<Tip label={<><b>{multi ? `Copy ${selection.length}
|
|
391
|
+
<Tip label={<><b>{multi ? `Copy ${selection.length} paths` : 'Copy path'}</b><span className="k">⇧P</span></>}>
|
|
171
392
|
<button className="icon"
|
|
172
|
-
onClick={() => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
393
|
+
onClick={() => {
|
|
394
|
+
const brd = useStore.getState().board
|
|
395
|
+
const text = selectedFrames().map((f) => framePath(brd, f)).join('\n')
|
|
396
|
+
navigator.clipboard.writeText(text).then(
|
|
397
|
+
() => { toast(multi ? `${selection.length} paths copied` : 'path copied'); useStore.getState().pulsePath() },
|
|
398
|
+
() => toast('copy blocked - click the canvas first'))
|
|
399
|
+
}}>{copied ? <CheckIcon size={15} /> : <SignpostIcon size={15} />}</button>
|
|
176
400
|
</Tip>
|
|
177
401
|
</div>
|
|
178
402
|
)
|
|
@@ -316,6 +540,7 @@ export function App() {
|
|
|
316
540
|
useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
|
|
317
541
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
318
542
|
const [pillOpen, setPillOpen] = useState(true)
|
|
543
|
+
const cm = useContextMenu() // shared sidebar right-click menu (boards + scenes)
|
|
319
544
|
|
|
320
545
|
// boot honors the deep link: board before load, play mode after it.
|
|
321
546
|
// Selection + camera intent are restored by the Canvas boot effect. The module-level
|
|
@@ -485,6 +710,11 @@ export function App() {
|
|
|
485
710
|
const s = useStore.getState()
|
|
486
711
|
|
|
487
712
|
if (data.type === 'sh:ready') {
|
|
713
|
+
// a WindowProxy survives navigation, so a ready queued by a SUPERSEDED document (one the
|
|
714
|
+
// ready-watchdog auto-renavigated past) can arrive here; drop it by generation like
|
|
715
|
+
// sh:measure does, so a stale ready never marks a reloading frame ready.
|
|
716
|
+
const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
|
|
717
|
+
if (String(data.gen ?? '') !== gen) return
|
|
488
718
|
s.setStatus(nodeKey, 'ready')
|
|
489
719
|
} else if (data.type === 'sh:error') {
|
|
490
720
|
s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
|
|
@@ -501,12 +731,13 @@ export function App() {
|
|
|
501
731
|
// frame-id match, finite positive, clamped)
|
|
502
732
|
s.measureNode(nodeKey, String(data.frame ?? ''), Number(data.ownWidth), Number(data.measuredWidth), Number(data.height))
|
|
503
733
|
} else if (data.type === 'sh:laser-copy') {
|
|
504
|
-
// laser click = copy the element's full address for the agent:
|
|
505
|
-
// frame source file + css path
|
|
734
|
+
// laser click = copy the element's full address for the agent: WHERE it lives on
|
|
735
|
+
// the canvas ([board ▸ scene]) + frame source file + css path (+ jsx source loc)
|
|
506
736
|
const n = s.nodes.find((x) => x.key === nodeKey)
|
|
507
737
|
const f = n && s.frameFor(n)
|
|
508
|
-
|
|
509
|
-
|
|
738
|
+
// drop a stale post that outran a frame swap (the sender echoes its frame id)
|
|
739
|
+
if (f && String(data.id ?? '') === f.id) {
|
|
740
|
+
const addr = `[${s.board} ▸ ${f.scene || '(root)'}] ${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
|
|
510
741
|
// success confirms IN the frame's hover label (right where the eyes are);
|
|
511
742
|
// only failure needs the toast
|
|
512
743
|
navigator.clipboard.writeText(addr).then(
|
|
@@ -611,12 +842,14 @@ export function App() {
|
|
|
611
842
|
toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
|
|
612
843
|
}
|
|
613
844
|
if (e.key === 'P' && e.shiftKey && s.selection.length) {
|
|
614
|
-
const
|
|
615
|
-
.map((k) => { const n = s.nodes.find((x) => x.key === k);
|
|
616
|
-
.filter((
|
|
617
|
-
if (
|
|
618
|
-
|
|
619
|
-
|
|
845
|
+
const paths = s.selection
|
|
846
|
+
.map((k) => { const n = s.nodes.find((x) => x.key === k); const f = n && s.frameFor(n); return f ? framePath(s.board, f) : undefined })
|
|
847
|
+
.filter((p): p is string => !!p)
|
|
848
|
+
if (paths.length) {
|
|
849
|
+
// pulse the toolbar icon into a check ONLY on a real copy success
|
|
850
|
+
navigator.clipboard.writeText(paths.join('\n')).then(
|
|
851
|
+
() => { toast(paths.length > 1 ? `${paths.length} paths copied` : 'path copied'); s.pulsePath() },
|
|
852
|
+
() => toast('copy blocked - click the canvas first'))
|
|
620
853
|
}
|
|
621
854
|
}
|
|
622
855
|
if (e.key === 'd' && CONFIG.themes.length > 1) {
|
|
@@ -707,11 +940,18 @@ export function App() {
|
|
|
707
940
|
</div>
|
|
708
941
|
<div className="sh-panel-scroll">
|
|
709
942
|
<div className="hd">Boards</div>
|
|
710
|
-
<BoardList />
|
|
943
|
+
<BoardList onMenu={cm.open} />
|
|
711
944
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
712
945
|
{scenes.map((sc) => (
|
|
713
946
|
<SceneGroup key={sc.name} name={sc.name} count={sc.frames}
|
|
714
947
|
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
948
|
+
onContextMenu={(e) => cm.open(e, [{
|
|
949
|
+
label: 'Copy path',
|
|
950
|
+
icon: <SignpostIcon size={15} />,
|
|
951
|
+
onClick: () => copyToClipboard(sc.name
|
|
952
|
+
? `board: ${useStore.getState().board} · scene: ${sc.name} (design/scenes/${sc.name}/)`
|
|
953
|
+
: `board: ${useStore.getState().board} · scene: (root)`, 'path copied'),
|
|
954
|
+
}])}
|
|
715
955
|
onPick={() => {
|
|
716
956
|
const keys = nodes.filter((n) => frames.some((f) => f.scene === sc.name && f.id === n.frame) && !n.missing).map((n) => n.key)
|
|
717
957
|
if (!keys.length) return
|
|
@@ -744,6 +984,9 @@ export function App() {
|
|
|
744
984
|
select(n.key, shift)
|
|
745
985
|
if (!shift) canvasCtl.fitNode(n.key)
|
|
746
986
|
}
|
|
987
|
+
const frameMenu = (fr: FrameEntry): MenuItem[] => [
|
|
988
|
+
{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(framePath(useStore.getState().board, fr), 'path copied') },
|
|
989
|
+
]
|
|
747
990
|
const seen = new Set<string>()
|
|
748
991
|
const rows: ReactNode[] = []
|
|
749
992
|
for (const f of sceneFrames) {
|
|
@@ -776,7 +1019,8 @@ export function App() {
|
|
|
776
1019
|
const on = !!n && selection.includes(n.key)
|
|
777
1020
|
const nm = m.title ?? cap((m.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
778
1021
|
rows.push(
|
|
779
|
-
<div key={m.id} className={`sub vrow${on ? ' on' : ''}`} onClick={(e) => go(m.id, e.shiftKey)}
|
|
1022
|
+
<div key={m.id} className={`sub vrow${on ? ' on' : ''}`} onClick={(e) => go(m.id, e.shiftKey)}
|
|
1023
|
+
onContextMenu={(e) => cm.open(e, frameMenu(m))}>
|
|
780
1024
|
<span className={`chip${on ? ' on' : ''}`}>{(m.variant ?? '?').toUpperCase()}</span><span className="nm">{nm}</span>
|
|
781
1025
|
</div>,
|
|
782
1026
|
)
|
|
@@ -787,7 +1031,7 @@ export function App() {
|
|
|
787
1031
|
const n = nodeFor(f.id)
|
|
788
1032
|
const on = !!n && selection.includes(n.key)
|
|
789
1033
|
rows.push(
|
|
790
|
-
<div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)} title={f.intent}>
|
|
1034
|
+
<div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)} onContextMenu={(e) => cm.open(e, frameMenu(f))} title={f.intent}>
|
|
791
1035
|
{/* every frame leads with an icon: intent glyph for content
|
|
792
1036
|
frames, the plain frame rectangle for UI frames */}
|
|
793
1037
|
{f.intent
|
|
@@ -841,6 +1085,7 @@ export function App() {
|
|
|
841
1085
|
|
|
842
1086
|
<PlayOverlay />
|
|
843
1087
|
<CommentsController />
|
|
1088
|
+
<ContextMenu menu={cm.menu} close={cm.close} />
|
|
844
1089
|
|
|
845
1090
|
{CONFIG.setup
|
|
846
1091
|
? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
|
|
@@ -364,12 +364,13 @@ function PlayInner() {
|
|
|
364
364
|
const c = commentsStore()
|
|
365
365
|
if (c.active) c.setActive(null)
|
|
366
366
|
} else if (data.type === 'sh:laser-copy') {
|
|
367
|
-
// laser click in the prototype: copy the element's address (
|
|
368
|
-
// and confirm in the frame's own hover label,
|
|
367
|
+
// laser click in the prototype: copy the element's address ([board ▸ scene] +
|
|
368
|
+
// frame file + css path) and confirm in the frame's own hover label, like the canvas
|
|
369
369
|
const at = s.play?.at
|
|
370
370
|
const f = at ? s.manifest?.frames.find((x) => x.id === at) : undefined
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
// drop a stale post that outran a stage swap (the sender echoes its frame id)
|
|
372
|
+
if (f && String(data.id ?? '') === at) {
|
|
373
|
+
const addr = `[${s.board} ▸ ${f.scene || '(root)'}] ${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
|
|
373
374
|
navigator.clipboard.writeText(addr).then(
|
|
374
375
|
() => postStage({ type: 'sh:copy-ok', seq: data.seq }),
|
|
375
376
|
() => s.toast('copy blocked - click the canvas first'))
|
|
@@ -7,6 +7,7 @@ import { threadHostKey } from '../keys.ts'
|
|
|
7
7
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
8
8
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
9
9
|
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
10
|
+
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
|
|
10
11
|
|
|
11
12
|
export const HEADER = 28
|
|
12
13
|
const SNAP = 12
|
|
@@ -47,7 +48,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
47
48
|
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
48
49
|
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
49
50
|
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
|
|
50
|
-
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
|
|
51
|
+
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
|
|
51
52
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
52
53
|
const themeRef = useRef(node.theme)
|
|
53
54
|
// src is frozen at mount: theme changes ride sh:set-theme (never navigation), so
|
|
@@ -204,12 +205,40 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
204
205
|
if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
|
|
205
206
|
}, [node.nav])
|
|
206
207
|
|
|
207
|
-
//
|
|
208
|
+
// Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
|
|
209
|
+
// instant a stale sh:ready (queued by the document we just superseded) could be processed - the
|
|
210
|
+
// App generation guard then drops it against the new src. reloadFrame bumps nav+manifestRev; we
|
|
211
|
+
// pre-apply the src here and advance navRef so the passive nav effect does not navigate a 2nd time.
|
|
212
|
+
const reload = (automatic: boolean) => {
|
|
213
|
+
const before = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
214
|
+
if (!before || (automatic && !canAutoReload(before))) return
|
|
215
|
+
reloadFrame(node.key, automatic)
|
|
216
|
+
const after = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
217
|
+
const f = useStore.getState().frameFor(node)
|
|
218
|
+
if (after && f && iframeRef.current) {
|
|
219
|
+
iframeRef.current.src = frameUrl(f, node.theme)
|
|
220
|
+
navRef.current = after.nav ?? 0
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// latest-callback ref so the watchdog effect can call the current reload() without taking it as a
|
|
224
|
+
// dep (an unstable function there would reset the timer every render and starve the retry)
|
|
225
|
+
const reloadRef = useRef(reload)
|
|
226
|
+
reloadRef.current = reload
|
|
227
|
+
|
|
228
|
+
// ready watchdog: a slow dev server (Vite re-optimizing deps, or the box saturated by parallel
|
|
229
|
+
// Live Jam agents) can leave boot()'s module fetches unresolved past the deadline - that is a slow
|
|
230
|
+
// frame, NOT a failed one (a real failure posts sh:error immediately). So the first silent deadline
|
|
231
|
+
// auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
|
|
232
|
+
// node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
|
|
233
|
+
// retry and bounds it to exactly one.
|
|
208
234
|
useEffect(() => {
|
|
209
|
-
if (node
|
|
210
|
-
const t = setTimeout(() =>
|
|
235
|
+
if (!shouldArmReadyWatch(node, !!frame)) return
|
|
236
|
+
const t = setTimeout(() => reloadRef.current(true), 10_000)
|
|
211
237
|
return () => clearTimeout(t)
|
|
212
|
-
|
|
238
|
+
// depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
|
|
239
|
+
// every manifest reconcile, so depending on it would reset the budget on unrelated frames during
|
|
240
|
+
// heavy Live Jam churn and starve the retry. kind/file still re-arm on a real file swap.
|
|
241
|
+
}, [node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
|
|
213
242
|
|
|
214
243
|
const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
|
|
215
244
|
e.stopPropagation()
|
|
@@ -345,7 +374,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
345
374
|
<span className="msg">{node.error}</span>
|
|
346
375
|
<span className="dim">{frame.file}</span>
|
|
347
376
|
<span className="row">
|
|
348
|
-
<button className="sh-no-pan" onClick={() =>
|
|
377
|
+
<button className="sh-no-pan" onClick={() => reload(false)}>
|
|
349
378
|
<ReloadIcon size={12} /> reload
|
|
350
379
|
</button>
|
|
351
380
|
<button className="sh-no-pan" onClick={() => { navigator.clipboard.writeText(`${frame.file}: ${node.error}`); toast('error copied for agent') }}>
|
|
@@ -354,6 +383,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
354
383
|
</span>
|
|
355
384
|
</div>
|
|
356
385
|
) : null}
|
|
386
|
+
{/* a frame still silent after its one auto-retry: a slow dev server, never a failure. A quiet
|
|
387
|
+
non-covering pill (not the red card) keeps it honest and offers a manual reload. */}
|
|
388
|
+
{node.status === 'loading' && node.readyRetried ? (
|
|
389
|
+
<div className="sh-loading sh-no-pan">
|
|
390
|
+
<span>still loading</span>
|
|
391
|
+
<button className="sh-no-pan" onClick={() => reload(false)}>
|
|
392
|
+
<ReloadIcon size={11} /> reload
|
|
393
|
+
</button>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
357
396
|
<iframe
|
|
358
397
|
ref={bindIframe}
|
|
359
398
|
className="sh-live"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Readiness-watchdog policy, kept pure so it can be unit-tested apart from the browser-coupled
|
|
2
|
+
// store (which imports Vite virtual modules and can't load under vitest).
|
|
3
|
+
//
|
|
4
|
+
// The frame host posts sh:ready SYNCHRONOUSLY after it renders, so a frame that misses the
|
|
5
|
+
// deadline has not failed - its dev-server module fetches just haven't settled (Vite re-optimizing
|
|
6
|
+
// dependencies, or the box saturated by parallel Live Jam agents). A genuine failure takes the
|
|
7
|
+
// separate immediate sh:error path. So a silent deadline earns exactly ONE automatic re-navigation
|
|
8
|
+
// on a fresh revision; a second silence stays 'loading' forever (never a red failure card).
|
|
9
|
+
|
|
10
|
+
export interface ReadyNode {
|
|
11
|
+
status: 'loading' | 'ready' | 'error'
|
|
12
|
+
readyRetried?: boolean
|
|
13
|
+
missing?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Arm the 10s watchdog only for a present, still-loading, not-yet-retried frame. A missing or
|
|
17
|
+
* absent frame must NOT spend the one-shot allowance - it has no live iframe booting, and would
|
|
18
|
+
* otherwise burn its retry before its file even arrives. */
|
|
19
|
+
export function shouldArmReadyWatch(node: ReadyNode, hasFrame: boolean): boolean {
|
|
20
|
+
return node.status === 'loading' && !node.readyRetried && hasFrame && !node.missing
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether an automatic (watchdog-driven) reload still applies: one shot, and only while the frame
|
|
24
|
+
* is still silently loading. A frame that already went ready/error, or already spent its retry,
|
|
25
|
+
* is left alone. */
|
|
26
|
+
export function canAutoReload(node: ReadyNode): boolean {
|
|
27
|
+
return node.status === 'loading' && !node.readyRetried
|
|
28
|
+
}
|