@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.
@@ -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, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
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
- function BoardList() {
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
- <button key={n} className={`it board${n === board ? ' cur' : ''}`} onClick={() => pick(n)}>
74
- {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
75
- <span>{boardLabel(n)}</span>
76
- </button>
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, spawn, toast } = useStore.getState()
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} file paths` : 'Copy file path'}</b><span className="k">⇧P</span></>}>
391
+ <Tip label={<><b>{multi ? `Copy ${selection.length} paths` : 'Copy path'}</b><span className="k">⇧P</span></>}>
171
392
  <button className="icon"
172
- onClick={() => { navigator.clipboard.writeText(selectedFrames().map((f) => f.file).join('\n')); toast(multi ? `${selection.length} file paths copied` : 'file path copied') }}><SignpostIcon size={15} /></button>
173
- </Tip>
174
- <Tip label={multi ? 'Duplicate frames' : 'Duplicate frame'}>
175
- <button className="icon" onClick={() => selectedFrames().forEach((f) => spawn(f.id))}><PlusIcon size={15} /></button>
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 inside it (+ jsx source loc when stamped)
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
- if (f) {
509
- const addr = `${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
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 files = s.selection
615
- .map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
616
- .filter((f): f is string => !!f)
617
- if (files.length) {
618
- navigator.clipboard.writeText(files.join('\n'))
619
- toast(files.length > 1 ? `${files.length} file paths copied` : 'file path copied')
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 (frame file + css path)
368
- // and confirm in the frame's own hover label, exactly like the canvas does
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
- if (f) {
372
- const addr = `${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
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
- // ready timeout: 10s without sh:ready -> error card with reload
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.status !== 'loading') return
210
- const t = setTimeout(() => setStatus(node.key, 'error', 'frame never reported ready (10s)'), 10_000)
235
+ if (!shouldArmReadyWatch(node, !!frame)) return
236
+ const t = setTimeout(() => reloadRef.current(true), 10_000)
211
237
  return () => clearTimeout(t)
212
- }, [node.status, node.key, setStatus])
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={() => { setStatus(node.key, 'loading'); iframeRef.current && (iframeRef.current.src = frameUrl(frame, node.theme)) }}>
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
+ }