@marver-design/marver 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +2 -1
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
  6. package/dist/cli.mjs +13 -4
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
  12. package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +4 -246
  17. package/src/client/shell/BoardList.tsx +408 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/icons.tsx +6 -0
  20. package/src/client/shell/store.ts +105 -47
  21. package/src/client/shell/styles.css +36 -4
  22. package/src/shared/board-tree.ts +285 -0
  23. package/templates/AGENTS-embedded.md +18 -5
  24. package/templates/AGENTS-studio.md +18 -5
  25. package/templates/instructions/boards.md +73 -2
  26. package/templates/instructions/craft.md +4 -0
  27. package/templates/instructions/discover.md +7 -2
  28. package/templates/instructions/iterate.md +5 -4
  29. package/templates/instructions/review.md +4 -0
  30. package/templates/instructions/shape.md +1 -1
  31. package/templates/instructions/welcome.md +4 -1
  32. package/templates/instructions/wireframe.md +3 -0
  33. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -0,0 +1,408 @@
1
+ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
+ import { useStore, HAS_ALL_SCENES, PUBLISHED, boardLabel, fetchBoardTree, type TreeBase } from './store.ts'
3
+ import { canvasCtl } from './canvas/Canvas.tsx'
4
+ import { Tip } from './Tip.tsx'
5
+ import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
6
+ import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
7
+ import {
8
+ applyDrop, boardsIn, createFolder, deleteFolder, folderOf, foldersIn, isBoardName, isOwnSlot, moveBoard, newFolderSlot, renameFolder, resolveDrop, rootIndex, slugify,
9
+ type Drag, type Drop, type TreeItem,
10
+ } from '../../shared/board-tree.ts'
11
+
12
+ /**
13
+ * Boards live at the top of the sidebar - always visible, one click to switch - in ONE level
14
+ * of folders. The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
15
+ * fields plus the `_folders.json` registry; every mutation here (drag, new/rename/delete
16
+ * folder, move) is one optimistic tree write through `arrangeBoards`, replayed once on a
17
+ * 409 (someone else wrote first). The list refreshes on mount, window focus, a slow poll and
18
+ * every `sh:boards` broadcast, so agent-written boards and folders appear without a reload.
19
+ * Active board = accent icon + wash, same language as scenes.
20
+ */
21
+
22
+ const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer preference, never data
23
+ const readClosed = (): Record<string, true> => { try { return JSON.parse(localStorage.getItem(CLOSED_KEY) ?? '{}') } catch { return {} } }
24
+ const INDENT = 28 // px a board row indents inside a folder; left of it is the root gutter
25
+
26
+ /** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
27
+ * yet - drawn at root `index`, optionally with `board` already inside it. */
28
+ type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string }
29
+ /** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
30
+ type Intent = (tree: TreeItem[]) => TreeItem[] | null
31
+
32
+ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
33
+ const board = useStore((s) => s.board)
34
+ const [tree, setTree] = useState<TreeItem[]>([])
35
+ const [naming, setNaming] = useState<Naming | null>(null)
36
+ const [closed, setClosed] = useState<Record<string, true>>(readClosed)
37
+ const [drag, setDrag] = useState<Drag | null>(null)
38
+ const [drop, setDrop] = useState<Drop | null>(null)
39
+ // What the sidebar shows = the CONFIRMED tree (the last read of the files) with every
40
+ // unconfirmed intent projected on top, in order. A read never loses an optimistic move
41
+ // (the queue re-applies over whatever came back) and a failure never rolls back to another
42
+ // unconfirmed state - only ever to the files. Reads are latest-wins by sequence.
43
+ const confirmedRef = useRef<TreeItem[]>([])
44
+ const baseRef = useRef<TreeBase>({ boards: {}, folders: null }) // the hashes the confirmed tree was read from
45
+ const queueRef = useRef<Intent[]>([]) // applied on screen, not yet written
46
+ const flushing = useRef(false)
47
+ const loadSeq = useRef(0)
48
+ const commitBusy = useRef(false) // guards Enter+blur firing two commits
49
+ const lastErr = useRef('') // a server-side error (malformed registry, symlinked dir) toasts once per message
50
+ // drag runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
51
+ // the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
52
+ // the grabbing cursor for the whole drag via a body class.
53
+ const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; item: Drag; dragging: boolean; el: HTMLElement } | null>(null)
54
+ const treeRef = useRef(tree)
55
+ treeRef.current = tree
56
+ const namingRef = useRef(naming) // commit reads the LIVE naming state, never a stale closure (Escape, then a late blur)
57
+ namingRef.current = naming
58
+
59
+ /** The confirmed tree with the queue projected on top - what the human sees. */
60
+ const show = () => {
61
+ let t = confirmedRef.current
62
+ for (const intent of queueRef.current) t = intent(t) ?? t
63
+ treeRef.current = t
64
+ setTree(t)
65
+ }
66
+ const load = async (): Promise<boolean> => {
67
+ const seq = ++loadSeq.current
68
+ try {
69
+ const snap = await fetchBoardTree()
70
+ if (seq !== loadSeq.current) return false // an older read landing late never overwrites a newer one
71
+ confirmedRef.current = snap.tree
72
+ baseRef.current = snap.base
73
+ lastErr.current = ''
74
+ show()
75
+ return true
76
+ } catch (e) {
77
+ // a server-side error (a malformed registry, a symlinked boards dir) is worth saying
78
+ // out loud, once; transport failures keep the last known tree quietly
79
+ const msg = e instanceof Error ? e.message : ''
80
+ if (/design\/boards/.test(msg) && msg !== lastErr.current) { lastErr.current = msg; useStore.getState().toast(msg) }
81
+ return false
82
+ }
83
+ }
84
+ // an external read (poll, focus, sh:boards) never lands MID-FLUSH: a snapshot taken between
85
+ // a write's commit and its answer would project the same batch twice; it waits for the drain
86
+ const pendingRef = useRef(false)
87
+ const refresh = () => { if (flushing.current) { pendingRef.current = true; return } void load() }
88
+ useEffect(() => {
89
+ refresh()
90
+ const t = setInterval(refresh, 8000)
91
+ window.addEventListener('focus', refresh)
92
+ // an agent (or another tab) adding, writing or deleting a board file or the registry
93
+ // broadcasts sh:boards (coalesced server-side): a `folder` edit shows in ~300 ms, not 8 s
94
+ import.meta.hot?.on('sh:boards', refresh)
95
+ return () => { clearInterval(t); window.removeEventListener('focus', refresh); import.meta.hot?.off('sh:boards', refresh) }
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ }, [])
98
+
99
+ const setOpen = (folder: string, open: boolean) => {
100
+ setClosed((c) => {
101
+ const next = { ...c }
102
+ if (open) delete next[folder]; else next[folder] = true
103
+ try { localStorage.setItem(CLOSED_KEY, JSON.stringify(next)) } catch { /* private mode */ }
104
+ return next
105
+ })
106
+ }
107
+ const pick = async (name: string) => {
108
+ if (name === useStore.getState().board) return
109
+ await useStore.getState().switchBoard(name)
110
+ setTimeout(() => canvasCtl.fitAll(), 60)
111
+ }
112
+
113
+ /** The one write path: queue the intent, show it at once, persist. The flush takes EVERY
114
+ * queued intent as one batch over the confirmed tree and writes the result; a 409 (a
115
+ * concurrent agent or tab wrote first) re-reads the files and replays the whole batch on
116
+ * what is there now, once. A terminal failure drops the batch, says so, and re-reads -
117
+ * the screen returns to the files, never to another unconfirmed state. Returns false when
118
+ * the intent cannot apply to what is shown (nothing queued). */
119
+ const mutate = (intent: Intent): boolean => {
120
+ if (!intent(treeRef.current)) return false
121
+ queueRef.current.push(intent)
122
+ show()
123
+ void flush()
124
+ return true
125
+ }
126
+ const flush = async () => {
127
+ if (flushing.current) return
128
+ flushing.current = true
129
+ try {
130
+ while (queueRef.current.length) {
131
+ const batch = [...queueRef.current]
132
+ // an intent the fresh tree no longer admits (its board or folder vanished) is dropped
133
+ // and said out loud, never silently counted as done
134
+ const reduce = (t: TreeItem[]) => {
135
+ let dropped = 0
136
+ const tree = batch.reduce<TreeItem[]>((acc, i) => { const n = i(acc); if (!n) dropped++; return n ?? acc }, t)
137
+ if (dropped) useStore.getState().toast(dropped === batch.length ? 'that move no longer applies' : 'some moves no longer apply')
138
+ return tree
139
+ }
140
+ const done = () => { queueRef.current.splice(0, batch.length); show() } // written, or given up on - off the screen's projection either way
141
+ try {
142
+ let r = await useStore.getState().arrangeBoards(reduce(confirmedRef.current), baseRef.current)
143
+ if (!r.ok && r.stale) {
144
+ if (!(await load())) { done(); useStore.getState().toast('boards changed - try again'); break }
145
+ r = await useStore.getState().arrangeBoards(reduce(confirmedRef.current), baseRef.current)
146
+ }
147
+ done()
148
+ if (!r.ok) { useStore.getState().toast(r.error ?? 'could not save order'); await load(); break }
149
+ await load() // the write moved the hashes on; the next batch needs the true base
150
+ } catch { done(); useStore.getState().toast('could not save order'); await load(); break }
151
+ }
152
+ } finally {
153
+ flushing.current = false
154
+ if (queueRef.current.length) void flush()
155
+ else if (pendingRef.current) { pendingRef.current = false; void load() }
156
+ }
157
+ }
158
+
159
+ // ---- inline naming (rename a board, rename a folder, name a new folder) ----
160
+ const commit = async (raw: string) => {
161
+ const n = namingRef.current
162
+ if (!n || commitBusy.current) return // Enter already fired this (or Escape cancelled); ignore the follow-up blur
163
+ commitBusy.current = true
164
+ try {
165
+ if (n.kind === 'board') {
166
+ const next = raw.trim()
167
+ if (!next || next === n.name) { setNaming(null); return }
168
+ if (!isBoardName(next) || next === 'all-scenes' || boardsIn(tree).includes(next)) {
169
+ useStore.getState().toast('use a free name - lowercase letters, numbers and dashes'); return // stay editing
170
+ }
171
+ const r = await useStore.getState().renameBoard(n.name, next)
172
+ if (r.ok) { setNaming(null); refresh() }
173
+ else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
174
+ return
175
+ }
176
+ // folders: what the human types becomes a slug ("Old stuff" -> old-stuff, shown "Old Stuff");
177
+ // an empty entry means never mind
178
+ if (!raw.trim() || (n.kind === 'folder' && slugify(raw) === n.name)) { setNaming(null); return }
179
+ const slug = slugify(raw)
180
+ if (!slug) { useStore.getState().toast('use letters, numbers and dashes'); return }
181
+ if (foldersIn(tree).includes(slug)) { useStore.getState().toast(`a folder named "${slug}" already exists`); return }
182
+ setNaming(null)
183
+ if (n.kind === 'folder') {
184
+ const wasClosed = !!closed[n.name]
185
+ setOpen(n.name, true); setOpen(slug, !wasClosed) // the collapsed flag follows the name, and only that
186
+ mutate((t) => renameFolder(t, n.name, slug))
187
+ } else if (n.kind === 'new') {
188
+ setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
189
+ if (!mutate((t) => createFolder(t, slug, n.index, n.board))) useStore.getState().toast('that board is gone - nothing changed')
190
+ }
191
+ } finally { commitBusy.current = false }
192
+ }
193
+
194
+ // ---- drag and drop: one pointer gesture for board rows and folder rows ----
195
+ /** The drop target under the pointer for the item being dragged, or null. The DOM hit becomes
196
+ * plain facts (which row, which half, the top edge, the root gutter) for the pure resolver. */
197
+ const dropAt = (x: number, y: number, d: Drag): Drop | null => {
198
+ const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row],[data-folder-row]') as HTMLElement | null
199
+ if (!el) return null
200
+ const r = el.getBoundingClientRect()
201
+ const hit = {
202
+ kind: (el.hasAttribute('data-folder-row') ? 'folder' : 'board') as Drag['kind'],
203
+ name: el.dataset.folderRow ?? el.dataset.board ?? '',
204
+ parent: el.hasAttribute('data-folder-row') ? null : (el.dataset.folder ?? null),
205
+ below: y > r.top + r.height / 2,
206
+ topEdge: y < r.top + r.height * 0.3,
207
+ gutter: x < r.left + INDENT,
208
+ }
209
+ const target = resolveDrop(treeRef.current, d, hit)
210
+ return target && !isOwnSlot(treeRef.current, d, target) ? target : null
211
+ }
212
+ const resetPointer = () => {
213
+ const g = gestureRef.current
214
+ gestureRef.current = null
215
+ if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
216
+ document.body.classList.remove('sh-board-dragging')
217
+ setDrag(null)
218
+ setDrop(null)
219
+ }
220
+ const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, item: Drag) => {
221
+ if (e.button !== 0) return // left button only; right-click opens the menu
222
+ const el = e.currentTarget
223
+ try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
224
+ gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, item, dragging: false, el }
225
+ }
226
+ const onPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
227
+ const g = gestureRef.current
228
+ if (!g || g.pointerId !== e.pointerId) return
229
+ if (!g.dragging) {
230
+ if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
231
+ g.dragging = true
232
+ setDrag(g.item)
233
+ document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
234
+ }
235
+ setDrop(dropAt(e.clientX, e.clientY, g.item))
236
+ }
237
+ const onPointerUp = (e: ReactPointerEvent<HTMLButtonElement>) => {
238
+ const g = gestureRef.current
239
+ if (!g || g.pointerId !== e.pointerId) return
240
+ // the item is the one the gesture STARTED on - never the row that happens to receive the
241
+ // up (capture can be lost, a refresh can re-key the dragged row)
242
+ const item = g.item
243
+ const dragged = g.dragging
244
+ const target = dragged ? dropAt(e.clientX, e.clientY, item) : null
245
+ resetPointer()
246
+ if (dragged) {
247
+ if (!target) return
248
+ if ('into' in target) setOpen(target.into, true) // show where it landed
249
+ mutate((t) => applyDrop(t, item, target))
250
+ }
251
+ else if (item.kind === 'board') void pick(item.name) // a tap switches boards (the trailing mouse click is ignored)
252
+ else setOpen(item.name, !!closed[item.name]) // a tap on a folder toggles it
253
+ }
254
+ // cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
255
+ useEffect(() => {
256
+ const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
257
+ const onBlur = () => { if (gestureRef.current) resetPointer() }
258
+ window.addEventListener('keydown', onEsc, true)
259
+ window.addEventListener('blur', onBlur)
260
+ return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
261
+ // eslint-disable-next-line react-hooks/exhaustive-deps
262
+ }, [])
263
+
264
+ // ---- menus (flat lists, no submenus) ----
265
+ const newFolderAt = (index: number, withBoard?: string) => setNaming({ kind: 'new', index, board: withBoard })
266
+ const boardMenu = (n: string, parent: string | null): MenuItem[] => {
267
+ const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
268
+ if (PUBLISHED || n === 'all-scenes') return items
269
+ items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
270
+ // the new folder takes the board's own slot (or the slot after its current folder)
271
+ items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(newFolderSlot(treeRef.current, n), n) })
272
+ // moving into an EXISTING folder is a drag, not a menu - the list stays short
273
+ if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => { const p = folderOf(t, n); return moveBoard(t, n, null, p ? rootIndex(t, 'folder', p) + 1 : undefined) }) })
274
+ return items
275
+ }
276
+ const folderMenu = (f: string, boards: string[]): MenuItem[] => {
277
+ const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
278
+ if (PUBLISHED) return items
279
+ items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'folder', name: f }) })
280
+ // folders organise, never own: deleting one puts its boards back at the top level, in order
281
+ items.push({ label: 'Delete folder', icon: <FolderMinusIcon size={15} />, onClick: () => { setOpen(f, true); mutate((t) => deleteFolder(t, f)) } })
282
+ return items
283
+ }
284
+ /** Right-click on the sidebar itself (the Boards header, gaps between rows, the blank space
285
+ * under the lists): New folder, appended at the end. Rows keep their own menus. */
286
+ const blankMenu = (e: { preventDefault(): void; clientX: number; clientY: number; target: EventTarget | null }) => {
287
+ if (PUBLISHED) return
288
+ if ((e.target as HTMLElement).closest('[data-board-row],[data-folder-row],.editing,.sh-hd-add')) return
289
+ onMenuRef.current(e, [{ label: 'New folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(treeRef.current.length) }])
290
+ }
291
+ const onMenuRef = useRef(onMenu)
292
+ onMenuRef.current = onMenu
293
+ const rootRef = useRef<HTMLDivElement>(null)
294
+ useEffect(() => {
295
+ const panel = rootRef.current?.closest('.sh-panel')
296
+ const scroll = rootRef.current?.closest('.sh-panel-scroll')
297
+ if (!panel || !scroll) return
298
+ const h = (e: Event) => { if (e.target === scroll || e.target === panel) blankMenu(e as MouseEvent) } // only the panel's own blank space
299
+ panel.addEventListener('contextmenu', h)
300
+ return () => panel.removeEventListener('contextmenu', h)
301
+ // eslint-disable-next-line react-hooks/exhaustive-deps
302
+ }, [])
303
+
304
+ // ---- rows ----
305
+ const input = (defaultValue: string, placeholder?: string) => (
306
+ <input autoFocus defaultValue={defaultValue} placeholder={placeholder} spellCheck={false}
307
+ onFocus={(e) => e.currentTarget.select()}
308
+ onKeyDown={(e) => {
309
+ if (e.key === 'Enter') { e.preventDefault(); void commit(e.currentTarget.value) }
310
+ else if (e.key === 'Escape') { e.preventDefault(); setNaming(null) }
311
+ }}
312
+ onBlur={(e) => { if (namingRef.current) void commit(e.currentTarget.value) }} />
313
+ )
314
+ // ONE seam per gap, from the insertion index: drop-before the row AT that index, or drop-after
315
+ // the last row of a folder's list. Overlay (::after), so no layout shift. Never on the row
316
+ // being dragged. The root end slot draws before the pinned all-scenes row, never after a
317
+ // folder header (that seam would read as "inside").
318
+ const seam = (list: string | null, index: number, last: boolean): string => {
319
+ if (!drag || !drop || 'into' in drop || drop.list !== list) return ''
320
+ return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
321
+ }
322
+ const boardRow = (n: string, parent: string | null, index: number, last: boolean) => {
323
+ if (naming?.kind === 'board' && naming.name === n) return (
324
+ <div key={n} className={`it board editing${parent ? ' in-folder' : ''}`}><CardsIcon size={14} />{input(n)}</div>
325
+ )
326
+ const canDrag = !PUBLISHED && n !== 'all-scenes'
327
+ const item: Drag = { kind: 'board', name: n }
328
+ const dragging = drag?.kind === 'board' && drag.name === n
329
+ const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
330
+ return (
331
+ <button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
332
+ className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
333
+ // draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
334
+ // click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
335
+ // (detail === 0) and non-draggable rows (all-scenes, published) switch here as normal
336
+ onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
337
+ onContextMenu={(e) => onMenu(e, boardMenu(n, parent))}
338
+ onPointerDown={canDrag ? (e) => onPointerDown(e, item) : undefined}
339
+ onPointerMove={canDrag ? onPointerMove : undefined}
340
+ onPointerUp={canDrag ? onPointerUp : undefined}
341
+ onPointerCancel={canDrag ? () => resetPointer() : undefined}
342
+ onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
343
+ {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
344
+ <span>{boardLabel(n)}</span>
345
+ </button>
346
+ )
347
+ }
348
+ const folderRow = (f: string, boards: string[], index: number): ReactNode[] => {
349
+ const open = !closed[f]
350
+ const rows: ReactNode[] = []
351
+ if (naming?.kind === 'folder' && naming.name === f) {
352
+ rows.push(<div key={`f:${f}`} className="it folder editing">{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(f)}</div>)
353
+ } else {
354
+ const item: Drag = { kind: 'folder', name: f }
355
+ const dragging = drag?.kind === 'folder' && drag.name === f
356
+ const into = !!drag && !!drop && 'into' in drop && drop.into === f
357
+ rows.push(
358
+ <button key={`f:${f}`} data-folder-row={f} data-reorderable={!PUBLISHED || undefined}
359
+ className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${dragging ? '' : seam(null, index, false)}`}
360
+ onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
361
+ onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
362
+ onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
363
+ onPointerMove={!PUBLISHED ? onPointerMove : undefined}
364
+ onPointerUp={!PUBLISHED ? onPointerUp : undefined}
365
+ onPointerCancel={!PUBLISHED ? () => resetPointer() : undefined}
366
+ onLostPointerCapture={!PUBLISHED ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
367
+ {open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}
368
+ <span>{boardLabel(f)}</span>
369
+ <small>{boards.length}</small>
370
+ </button>,
371
+ )
372
+ }
373
+ if (open) boards.forEach((b, i) => rows.push(boardRow(b, f, i, i === boards.length - 1)))
374
+ return rows
375
+ }
376
+
377
+ // a new folder being named is drawn at its future slot, its board (if any) already inside;
378
+ // that board leaves its usual row for the duration
379
+ const draft = naming?.kind === 'new' ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
380
+ const rows: ReactNode[] = []
381
+ const draftRows = draft ? [
382
+ <div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
383
+ ...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{boardLabel(draft.board)}</span></div>] : []),
384
+ ] : []
385
+ tree.forEach((it, i) => {
386
+ if (draft && draft.index === i) rows.push(...draftRows)
387
+ if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
388
+ else rows.push(...folderRow(it.name, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
389
+ })
390
+ if (draft && draft.index >= tree.length) rows.push(...draftRows)
391
+ if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
392
+ return (
393
+ <div className="sh-boards" ref={rootRef} onContextMenu={(e: ReactMouseEvent) => blankMenu(e)}>
394
+ <div className="hd">
395
+ <span>Boards</span>
396
+ {/* the quiet way in: a folder-plus on the header (the right-click menu is the other) */}
397
+ {!PUBLISHED && (
398
+ <Tip side="bottom" label="New folder">
399
+ <FolderPlusIcon size={17} className="sh-hd-add" role="button" tabIndex={0} aria-label="New folder"
400
+ onClick={() => newFolderAt(treeRef.current.length)}
401
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); newFolderAt(treeRef.current.length) } }} />
402
+ </Tip>
403
+ )}
404
+ </div>
405
+ {rows}
406
+ </div>
407
+ )
408
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { useStore, SOURCE_REVEALED } from './store.ts'
4
+
5
+ /** Copy text to the clipboard, toasting the outcome. Success is confirmed out loud; a
6
+ * blocked clipboard (no user gesture / permission) says so instead of failing silently. */
7
+ export function copyToClipboard(text: string, okMsg: string) {
8
+ const { toast } = useStore.getState()
9
+ navigator.clipboard.writeText(text).then(() => toast(okMsg), () => toast('copy blocked - click the canvas first'))
10
+ }
11
+
12
+ /** The address a frame copies - the same string from the sidebar right-click, the floating
13
+ * toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
14
+ // stripped build: the file column would only show the opaque token, so the
15
+ // copy keeps what is still honest - the board and the frame id
16
+ export const framePath = (board: string, f: { id: string; file: string }) =>
17
+ SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
18
+
19
+ export type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
20
+ export type MenuState = { x: number; y: number; items: MenuItem[] }
21
+ export type MenuOpener = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void
22
+
23
+ /** A right-click menu for the sidebar. One instance lives in App; `open(e, items)` positions
24
+ * it at the cursor, CLAMPED into the viewport. It closes on outside pointerdown, on pick, and
25
+ * on Escape - the Escape listener runs in CAPTURE phase so it does not also trip App's global
26
+ * keydown (which would clear the selection/laser underneath the menu). */
27
+ export function useContextMenu() {
28
+ const [menu, setMenu] = useState<MenuState | null>(null)
29
+ const open: MenuOpener = (e, items) => {
30
+ e.preventDefault()
31
+ const MENU_W = 240 // the CSS max-width; labels ellipsize inside it
32
+ // a menu taller than the window (many "Move to …" folders) pins to the top edge and scrolls
33
+ const h = Math.min(items.length * 32 + 12, window.innerHeight - 16)
34
+ setMenu({ x: Math.max(8, Math.min(e.clientX, window.innerWidth - MENU_W - 8)), y: Math.max(8, Math.min(e.clientY, window.innerHeight - h - 8)), items })
35
+ }
36
+ return { menu, open, close: () => setMenu(null) }
37
+ }
38
+
39
+ export function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
40
+ const ref = useRef<HTMLDivElement>(null)
41
+ useEffect(() => {
42
+ if (!menu) return
43
+ const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
44
+ const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close() } }
45
+ window.addEventListener('pointerdown', onDown)
46
+ window.addEventListener('keydown', onEsc, true) // capture: beat App's bubble-phase Escape
47
+ return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('keydown', onEsc, true) }
48
+ }, [menu])
49
+ const app = document.querySelector('.sh-app')
50
+ if (!menu || !app) return null
51
+ return createPortal(
52
+ <div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
53
+ {menu.items.map((it) => (
54
+ <button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
55
+ ))}
56
+ </div>,
57
+ app,
58
+ )
59
+ }
@@ -90,3 +90,9 @@ export function deviceIcon(name: string | null, size = 14) {
90
90
  }
91
91
 
92
92
  export const BrowsersIcon = icon(<path d="M216,40H72A16,16,0,0,0,56,56V72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V184h16a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM184,88v16H40V88Zm0,112H40V120H184v80Zm32-32H200V88a16,16,0,0,0-16-16H72V56H216Z" />)
93
+ /** Sidebar folders (Phosphor regular): closed, open, plus (new), minus (delete). */
94
+ export const FolderIcon = icon(<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z" />)
95
+ export const FolderOpenIcon = icon(<path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Zm112,136H43.1l26.67-80H232Z" />)
96
+ export const FolderPlusIcon = icon(<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM92.69,56l16,16H40V56ZM216,200H40V88H216Zm-88-88a8,8,0,0,1,8,8v16h16a8,8,0,0,1,0,16H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120A8,8,0,0,1,128,112Z" />)
97
+ export const FolderMinusIcon = icon(<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM92.69,56l16,16H40V56ZM216,200H40V88H216ZM104,136h48a8,8,0,0,1,0,16H104a8,8,0,0,1,0-16Z" />)
98
+ export const ArrowLineUpIcon = icon(<path d="M205.66,138.34a8,8,0,0,1-11.32,11.32L136,91.31V224a8,8,0,0,1-16,0V91.31L61.66,149.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0ZM216,32H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z" />)