@marver-design/marver 0.15.0 → 0.16.1

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 (36) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +2 -1
  3. package/dist/boards-BmxcT3Lc.mjs +290 -0
  4. package/dist/boards-PuVzw5Wp.mjs +62 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
  6. package/dist/cli.mjs +33 -13
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
  12. package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +45 -259
  17. package/src/client/shell/BoardList.tsx +438 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/LockedApp.tsx +2 -1
  20. package/src/client/shell/Play.tsx +1 -0
  21. package/src/client/shell/icons.tsx +6 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +156 -51
  24. package/src/client/shell/styles.css +40 -4
  25. package/src/shared/board-tree.ts +343 -0
  26. package/templates/AGENTS-embedded.md +25 -5
  27. package/templates/AGENTS-studio.md +25 -5
  28. package/templates/instructions/boards.md +91 -2
  29. package/templates/instructions/craft.md +4 -0
  30. package/templates/instructions/discover.md +20 -3
  31. package/templates/instructions/iterate.md +5 -4
  32. package/templates/instructions/review.md +4 -0
  33. package/templates/instructions/shape.md +1 -1
  34. package/templates/instructions/welcome.md +4 -1
  35. package/templates/instructions/wireframe.md +3 -0
  36. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -0,0 +1,438 @@
1
+ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
+ import { useStore, HAS_ALL_SCENES, PUBLISHED, fetchBoardTree, rememberTitles, 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, folderIn, folderOf, foldersIn, humanize, isOwnSlot, labelOf, moveBoard, newFolderSlot, readTitle, resolveDrop, retitleFolder, rootIndex, slugFor,
9
+ type Drag, type Drop, type Folder, type Row, 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
+
25
+ /** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
26
+ * yet - drawn at root `index`, optionally with `board` already inside it. */
27
+ type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string }
28
+ /** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
29
+ type Intent = (tree: TreeItem[]) => TreeItem[] | null
30
+
31
+ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
32
+ const board = useStore((s) => s.board)
33
+ const titles = useStore((s) => s.boardTitles) // board slug → title, off the last tree read
34
+ const label = (n: string) => labelOf(n, titles[n]) // a board's label; a folder's is labelOf(name, item.title)
35
+ const [tree, setTree] = useState<TreeItem[]>([])
36
+ const [naming, setNaming] = useState<Naming | null>(null)
37
+ const [closed, setClosed] = useState<Record<string, true>>(readClosed)
38
+ const [drag, setDrag] = useState<Drag | null>(null)
39
+ const [drop, setDrop] = useState<Drop | null>(null)
40
+ // What the sidebar shows = the CONFIRMED tree (the last read of the files) with every
41
+ // unconfirmed intent projected on top, in order. A read never loses an optimistic move
42
+ // (the queue re-applies over whatever came back) and a failure never rolls back to another
43
+ // unconfirmed state - only ever to the files. Reads are latest-wins by sequence.
44
+ const confirmedRef = useRef<TreeItem[]>([])
45
+ const baseRef = useRef<TreeBase>({ boards: {}, folders: null }) // the hashes the confirmed tree was read from
46
+ const queueRef = useRef<Intent[]>([]) // applied on screen, not yet written
47
+ const flushing = useRef(false)
48
+ const loadSeq = useRef(0)
49
+ const commitBusy = useRef(false) // guards Enter+blur firing two commits
50
+ const lastErr = useRef('') // a server-side error (malformed registry, symlinked dir) toasts once per message
51
+ // drag runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
52
+ // the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
53
+ // the grabbing cursor for the whole drag via a body class.
54
+ const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; item: Drag; dragging: boolean; el: HTMLElement; x: number; y: number } | null>(null)
55
+ const treeRef = useRef(tree)
56
+ treeRef.current = tree
57
+ const namingRef = useRef(naming) // commit reads the LIVE naming state, never a stale closure (Escape, then a late blur)
58
+ namingRef.current = naming
59
+
60
+ /** The confirmed tree with the queue projected on top - what the human sees. */
61
+ const show = () => {
62
+ let t = confirmedRef.current
63
+ for (const intent of queueRef.current) t = intent(t) ?? t
64
+ treeRef.current = t
65
+ setTree(t)
66
+ }
67
+ const load = async (): Promise<boolean> => {
68
+ const seq = ++loadSeq.current
69
+ try {
70
+ const snap = await fetchBoardTree()
71
+ if (seq !== loadSeq.current) return false // an older read landing late never overwrites a newer one
72
+ confirmedRef.current = snap.tree
73
+ baseRef.current = snap.base
74
+ rememberTitles(snap.titles) // the labels follow the same latest-wins rule
75
+ lastErr.current = ''
76
+ show()
77
+ return true
78
+ } catch (e) {
79
+ // a server-side error (a malformed registry, a symlinked boards dir) is worth saying
80
+ // out loud, once; transport failures keep the last known tree quietly
81
+ const msg = e instanceof Error ? e.message : ''
82
+ if (/design\/boards/.test(msg) && msg !== lastErr.current) { lastErr.current = msg; useStore.getState().toast(msg) }
83
+ return false
84
+ }
85
+ }
86
+ // an external read (poll, focus, sh:boards) never lands MID-FLUSH: a snapshot taken between
87
+ // a write's commit and its answer would project the same batch twice; it waits for the drain
88
+ const pendingRef = useRef(false)
89
+ const refresh = () => { if (flushing.current) { pendingRef.current = true; return } void load() }
90
+ useEffect(() => {
91
+ refresh()
92
+ const t = setInterval(refresh, 8000)
93
+ window.addEventListener('focus', refresh)
94
+ // an agent (or another tab) adding, writing or deleting a board file or the registry
95
+ // broadcasts sh:boards (coalesced server-side): a `folder` edit shows in ~300 ms, not 8 s
96
+ import.meta.hot?.on('sh:boards', refresh)
97
+ return () => { clearInterval(t); window.removeEventListener('focus', refresh); import.meta.hot?.off('sh:boards', refresh) }
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [])
100
+
101
+ const setOpen = (folder: string, open: boolean) => {
102
+ setClosed((c) => {
103
+ const next = { ...c }
104
+ if (open) delete next[folder]; else next[folder] = true
105
+ try { localStorage.setItem(CLOSED_KEY, JSON.stringify(next)) } catch { /* private mode */ }
106
+ return next
107
+ })
108
+ }
109
+ const pick = async (name: string) => {
110
+ if (name === useStore.getState().board) return
111
+ await useStore.getState().switchBoard(name)
112
+ setTimeout(() => canvasCtl.fitAll(), 60)
113
+ }
114
+
115
+ /** The one write path: queue the intent, show it at once, persist. The flush takes EVERY
116
+ * queued intent as one batch over the confirmed tree and writes the result; a 409 (a
117
+ * concurrent agent or tab wrote first) re-reads the files and replays the whole batch on
118
+ * what is there now, once. A terminal failure drops the batch, says so, and re-reads -
119
+ * the screen returns to the files, never to another unconfirmed state. Returns false when
120
+ * the intent cannot apply to what is shown (nothing queued). */
121
+ const mutate = (intent: Intent): boolean => {
122
+ if (!intent(treeRef.current)) return false
123
+ queueRef.current.push(intent)
124
+ show()
125
+ void flush()
126
+ return true
127
+ }
128
+ const flush = async () => {
129
+ if (flushing.current) return
130
+ flushing.current = true
131
+ try {
132
+ while (queueRef.current.length) {
133
+ const batch = [...queueRef.current]
134
+ // an intent the fresh tree no longer admits (its board or folder vanished) is dropped
135
+ // and said out loud, never silently counted as done
136
+ const reduce = (t: TreeItem[]) => {
137
+ let dropped = 0
138
+ const tree = batch.reduce<TreeItem[]>((acc, i) => { const n = i(acc); if (!n) dropped++; return n ?? acc }, t)
139
+ if (dropped) useStore.getState().toast(dropped === batch.length ? 'that move no longer applies' : 'some moves no longer apply')
140
+ return tree
141
+ }
142
+ const done = () => { queueRef.current.splice(0, batch.length); show() } // written, or given up on - off the screen's projection either way
143
+ try {
144
+ let r = await useStore.getState().arrangeBoards(reduce(confirmedRef.current), baseRef.current)
145
+ if (!r.ok && r.stale) {
146
+ if (!(await load())) { done(); useStore.getState().toast('boards changed - try again'); break }
147
+ r = await useStore.getState().arrangeBoards(reduce(confirmedRef.current), baseRef.current)
148
+ }
149
+ done()
150
+ if (!r.ok) { useStore.getState().toast(r.error ?? 'could not save order'); await load(); break }
151
+ await load() // the write moved the hashes on; the next batch needs the true base
152
+ } catch { done(); useStore.getState().toast('could not save order'); await load(); break }
153
+ }
154
+ } finally {
155
+ flushing.current = false
156
+ if (queueRef.current.length) void flush()
157
+ else if (pendingRef.current) { pendingRef.current = false; void load() }
158
+ }
159
+ }
160
+
161
+ // ---- inline naming (retitle a board, retitle a folder, name a new folder) ----
162
+ // What the human types is the TITLE - any casing, punctuation, emoji - what the row shows.
163
+ // The slug (the board's file name, the folder's key on its boards and in the registry) is
164
+ // the object's identity: agents, publish.json, URLs and comment threads hold it, so a rename
165
+ // never moves it. A new folder mints its slug from the title once. Typing exactly what the
166
+ // slug reads as anyway ("Research" for `research`) clears the title - the file stays clean.
167
+ // An empty entry, or the label unchanged, means never mind.
168
+ const commit = async (raw: string) => {
169
+ const n = namingRef.current
170
+ if (!n || commitBusy.current) return // Enter already fired this (or Escape cancelled); ignore the follow-up blur
171
+ commitBusy.current = true
172
+ try {
173
+ const title = readTitle(raw)
174
+ if (!title) { setNaming(null); return }
175
+ // two rows reading alike would be a trap: a name another board (folder) already shows stays editing
176
+ const taken = (kind: 'board' | 'folder') => useStore.getState().toast(`a ${kind} called "${title}" already exists`)
177
+ const stored = (slug: string) => (title === humanize(slug) ? '' : title)
178
+ if (n.kind === 'board') {
179
+ if (title === label(n.name)) { setNaming(null); return }
180
+ if (boardsIn(tree).some((b) => b !== n.name && label(b) === title) || (HAS_ALL_SCENES && label('all-scenes') === title)) { taken('board'); return }
181
+ let r = await useStore.getState().renameBoard(n.name, stored(n.name), baseRef.current.boards[n.name])
182
+ if (!r.ok && r.stale && await load()) r = await useStore.getState().renameBoard(n.name, stored(n.name), baseRef.current.boards[n.name]) // the file moved on (a drag just before, an agent): re-read, once more
183
+ if (!r.ok) { useStore.getState().toast(r.error ?? 'rename failed'); return } // stay editing
184
+ setNaming(null)
185
+ refresh()
186
+ return
187
+ }
188
+ const folderLabels = tree.filter((it): it is Folder => it.kind === 'folder' && (n.kind !== 'folder' || it.name !== n.name)).map((it) => labelOf(it.name, it.title))
189
+ if (n.kind === 'folder') {
190
+ const current = folderIn(tree, n.name)
191
+ if (!current || title === labelOf(n.name, current.title)) { setNaming(null); return }
192
+ if (folderLabels.includes(title)) { taken('folder'); return }
193
+ setNaming(null)
194
+ mutate((t) => retitleFolder(t, n.name, stored(n.name)))
195
+ return
196
+ }
197
+ if (n.kind !== 'new') return
198
+ if (folderLabels.includes(title)) { taken('folder'); return }
199
+ const { index, board: withBoard } = n
200
+ const slug = slugFor(title, foldersIn(tree)) // "Old stuff" → old-stuff (-2 past a namesake); "🚀" alone → folder
201
+ setNaming(null)
202
+ setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
203
+ if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined))) useStore.getState().toast('that board is gone - nothing changed')
204
+ } finally { commitBusy.current = false }
205
+ }
206
+
207
+ // ---- drag and drop: one pointer gesture for board rows and folder rows ----
208
+ /** The rows as rendered, measured - what the pure resolver reads the pointer against. */
209
+ const rootRef = useRef<HTMLDivElement>(null)
210
+ const measure = (): Row[] => Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[data-board-row],[data-folder-row]') ?? []).map((el) => {
211
+ const r = el.getBoundingClientRect()
212
+ const folder = el.hasAttribute('data-folder-row')
213
+ return { kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: folder ? null : (el.dataset.folder ?? null), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left }
214
+ })
215
+ /** The drop target for the pointer at (x, y), or null: outside the panel (a release there
216
+ * cancels), or a slot that would change nothing. Inside the panel there is always one - the
217
+ * ends clamp - so the seam on screen is exactly where a release lands. */
218
+ const dropAt = (x: number, y: number, d: Drag): Drop | null => {
219
+ const panel = rootRef.current?.closest('.sh-panel')?.getBoundingClientRect()
220
+ if (!panel || x < panel.left || x >= panel.right || y < panel.top || y >= panel.bottom) return null
221
+ const target = resolveDrop(treeRef.current, d, measure(), x, y)
222
+ return target && !isOwnSlot(treeRef.current, d, target) ? target : null
223
+ }
224
+ const dropRef = useRef<Drop | null>(null) // the target on screen; the release applies THIS, never a recomputation
225
+ const showDrop = (t: Drop | null) => { dropRef.current = t; setDrop(t) }
226
+ const resetPointer = () => {
227
+ const g = gestureRef.current
228
+ gestureRef.current = null
229
+ if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
230
+ document.body.classList.remove('sh-board-dragging')
231
+ setDrag(null)
232
+ showDrop(null)
233
+ }
234
+ const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, item: Drag) => {
235
+ if (e.button !== 0) return // left button only; right-click opens the menu
236
+ const el = e.currentTarget
237
+ try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
238
+ gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, item, dragging: false, el, x: e.clientX, y: e.clientY }
239
+ }
240
+ const onPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
241
+ const g = gestureRef.current
242
+ if (!g || g.pointerId !== e.pointerId) return
243
+ g.x = e.clientX; g.y = e.clientY
244
+ if (!g.dragging) {
245
+ if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
246
+ g.dragging = true
247
+ setDrag(g.item)
248
+ document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
249
+ }
250
+ showDrop(dropAt(e.clientX, e.clientY, g.item))
251
+ }
252
+ // the tree changed under a drag in progress (a poll, an agent's write): the seam is re-read
253
+ // against the rows as they are NOW, from where the pointer is - never a slot that no longer means it
254
+ useEffect(() => {
255
+ const g = gestureRef.current
256
+ if (g?.dragging) showDrop(dropAt(g.x, g.y, g.item))
257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
258
+ }, [tree])
259
+ const onPointerUp = (e: ReactPointerEvent<HTMLButtonElement>) => {
260
+ const g = gestureRef.current
261
+ if (!g || g.pointerId !== e.pointerId) return
262
+ // the item is the one the gesture STARTED on - never the row that happens to receive the
263
+ // up (capture can be lost, a refresh can re-key the dragged row); the target is the seam
264
+ // the human saw
265
+ const item = g.item
266
+ const dragged = g.dragging
267
+ // a release outside the panel cancels, even after a seam showed (the pointer can leave
268
+ // between the last move and the up); inside, the seam is the contract
269
+ const panel = rootRef.current?.closest('.sh-panel')?.getBoundingClientRect()
270
+ const inside = !!panel && e.clientX >= panel.left && e.clientX < panel.right && e.clientY >= panel.top && e.clientY < panel.bottom
271
+ const target = dragged && inside ? dropRef.current : null
272
+ resetPointer()
273
+ if (dragged) {
274
+ if (!target) return
275
+ if ('into' in target) setOpen(target.into, true) // show where it landed
276
+ mutate((t) => applyDrop(t, item, target))
277
+ }
278
+ else if (item.kind === 'board') void pick(item.name) // a tap switches boards (the trailing mouse click is ignored)
279
+ else setOpen(item.name, !!closed[item.name]) // a tap on a folder toggles it
280
+ }
281
+ // cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
282
+ useEffect(() => {
283
+ const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
284
+ const onBlur = () => { if (gestureRef.current) resetPointer() }
285
+ window.addEventListener('keydown', onEsc, true)
286
+ window.addEventListener('blur', onBlur)
287
+ return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
288
+ // eslint-disable-next-line react-hooks/exhaustive-deps
289
+ }, [])
290
+
291
+ // ---- menus (flat lists, no submenus) ----
292
+ const newFolderAt = (index: number, withBoard?: string) => setNaming({ kind: 'new', index, board: withBoard })
293
+ const boardMenu = (n: string, parent: string | null): MenuItem[] => {
294
+ const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
295
+ if (PUBLISHED || n === 'all-scenes') return items
296
+ items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
297
+ // the new folder takes the board's own slot (or the slot after its current folder)
298
+ items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(newFolderSlot(treeRef.current, n), n) })
299
+ // moving into an EXISTING folder is a drag, not a menu - the list stays short
300
+ 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) }) })
301
+ return items
302
+ }
303
+ const folderMenu = (f: string, boards: string[]): MenuItem[] => {
304
+ const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
305
+ if (PUBLISHED) return items
306
+ items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'folder', name: f }) })
307
+ // folders organise, never own: deleting one puts its boards back at the top level, in order
308
+ items.push({ label: 'Delete folder', icon: <FolderMinusIcon size={15} />, onClick: () => { setOpen(f, true); mutate((t) => deleteFolder(t, f)) } })
309
+ return items
310
+ }
311
+ /** Right-click on the sidebar itself (the Boards header, gaps between rows, the blank space
312
+ * under the lists): New folder, appended at the end. Rows keep their own menus. */
313
+ const blankMenu = (e: { preventDefault(): void; clientX: number; clientY: number; target: EventTarget | null }) => {
314
+ if (PUBLISHED) return
315
+ if ((e.target as HTMLElement).closest('[data-board-row],[data-folder-row],.editing,.sh-hd-add')) return
316
+ onMenuRef.current(e, [{ label: 'New folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(treeRef.current.length) }])
317
+ }
318
+ const onMenuRef = useRef(onMenu)
319
+ onMenuRef.current = onMenu
320
+ useEffect(() => {
321
+ const panel = rootRef.current?.closest('.sh-panel')
322
+ const scroll = rootRef.current?.closest('.sh-panel-scroll')
323
+ if (!panel || !scroll) return
324
+ const h = (e: Event) => { if (e.target === scroll || e.target === panel) blankMenu(e as MouseEvent) } // only the panel's own blank space
325
+ panel.addEventListener('contextmenu', h)
326
+ return () => panel.removeEventListener('contextmenu', h)
327
+ // eslint-disable-next-line react-hooks/exhaustive-deps
328
+ }, [])
329
+
330
+ // ---- rows ----
331
+ const input = (defaultValue: string, placeholder?: string) => (
332
+ <input autoFocus defaultValue={defaultValue} placeholder={placeholder} spellCheck={false}
333
+ onFocus={(e) => e.currentTarget.select()}
334
+ onKeyDown={(e) => {
335
+ if (e.key === 'Enter') { e.preventDefault(); void commit(e.currentTarget.value) }
336
+ else if (e.key === 'Escape') { e.preventDefault(); setNaming(null) }
337
+ }}
338
+ onBlur={(e) => { if (namingRef.current) void commit(e.currentTarget.value) }} />
339
+ )
340
+ // ONE seam per gap, from the insertion index: drop-before the row AT that index, or drop-after
341
+ // the last row of a folder's list. Overlay (::after), so no layout shift. Never on the row
342
+ // being dragged. The root end slot draws before the pinned all-scenes row, never after a
343
+ // folder header (that seam would read as "inside").
344
+ const seam = (list: string | null, index: number, last: boolean): string => {
345
+ if (!drag || !drop || 'into' in drop || drop.list !== list) return ''
346
+ return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
347
+ }
348
+ const boardRow = (n: string, parent: string | null, index: number, last: boolean) => {
349
+ const dragging = drag?.kind === 'board' && drag.name === n
350
+ const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
351
+ // the row being renamed is still a row: measured by a drag (its slot exists) and it draws its seam
352
+ if (naming?.kind === 'board' && naming.name === n) return (
353
+ <div key={n} data-board-row data-board={n} data-folder={parent ?? undefined} className={`it board editing${parent ? ' in-folder' : ''}${dropCls}`}><CardsIcon size={14} />{input(label(n))}</div>
354
+ )
355
+ const canDrag = !PUBLISHED && n !== 'all-scenes'
356
+ const item: Drag = { kind: 'board', name: n }
357
+ return (
358
+ <button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
359
+ className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
360
+ // draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
361
+ // click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
362
+ // (detail === 0) and non-draggable rows (all-scenes, published) switch here as normal
363
+ onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
364
+ onContextMenu={(e) => onMenu(e, boardMenu(n, parent))}
365
+ onPointerDown={canDrag ? (e) => onPointerDown(e, item) : undefined}
366
+ onPointerMove={canDrag ? onPointerMove : undefined}
367
+ onPointerUp={canDrag ? onPointerUp : undefined}
368
+ onPointerCancel={canDrag ? () => resetPointer() : undefined}
369
+ onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
370
+ {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
371
+ <span>{label(n)}</span>
372
+ </button>
373
+ )
374
+ }
375
+ const folderRow = (it: Folder, boards: string[], index: number): ReactNode[] => {
376
+ const f = it.name
377
+ const open = !closed[f]
378
+ const rows: ReactNode[] = []
379
+ if (naming?.kind === 'folder' && naming.name === f) {
380
+ rows.push(<div key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} className={`it folder editing${seam(null, index, false)}`}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
381
+ } else {
382
+ const item: Drag = { kind: 'folder', name: f }
383
+ const dragging = drag?.kind === 'folder' && drag.name === f
384
+ const into = !!drag && !!drop && 'into' in drop && drop.into === f
385
+ // a slot inside an open EMPTY folder has no board row to draw on: the indented seam sits under the header
386
+ const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && boards.length === 0
387
+ rows.push(
388
+ <button key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
389
+ className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${inSeam ? ' drop-in' : ''}${dragging ? '' : seam(null, index, false)}`}
390
+ onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
391
+ onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
392
+ onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
393
+ onPointerMove={!PUBLISHED ? onPointerMove : undefined}
394
+ onPointerUp={!PUBLISHED ? onPointerUp : undefined}
395
+ onPointerCancel={!PUBLISHED ? () => resetPointer() : undefined}
396
+ onLostPointerCapture={!PUBLISHED ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
397
+ {open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}
398
+ <span>{labelOf(f, it.title)}</span>
399
+ <small>{boards.length}</small>
400
+ </button>,
401
+ )
402
+ }
403
+ if (open) boards.forEach((b, i) => rows.push(boardRow(b, f, i, i === boards.length - 1)))
404
+ return rows
405
+ }
406
+
407
+ // a new folder being named is drawn at its future slot, its board (if any) already inside;
408
+ // that board leaves its usual row for the duration
409
+ 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
410
+ const rows: ReactNode[] = []
411
+ const draftRows = draft ? [
412
+ <div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
413
+ ...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
414
+ ] : []
415
+ tree.forEach((it, i) => {
416
+ if (draft && draft.index === i) rows.push(...draftRows)
417
+ if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
418
+ else rows.push(...folderRow(it, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
419
+ })
420
+ if (draft && draft.index >= tree.length) rows.push(...draftRows)
421
+ if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
422
+ return (
423
+ <div className="sh-boards" ref={rootRef} onContextMenu={(e: ReactMouseEvent) => blankMenu(e)}>
424
+ <div className="hd">
425
+ <span>Boards</span>
426
+ {/* the quiet way in: a folder-plus on the header (the right-click menu is the other) */}
427
+ {!PUBLISHED && (
428
+ <Tip side="bottom" label="New folder">
429
+ <FolderPlusIcon size={17} className="sh-hd-add" role="button" tabIndex={0} aria-label="New folder"
430
+ onClick={() => newFolderAt(treeRef.current.length)}
431
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); newFolderAt(treeRef.current.length) } }} />
432
+ </Tip>
433
+ )}
434
+ </div>
435
+ {rows}
436
+ </div>
437
+ )
438
+ }
@@ -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
+ }
@@ -107,7 +107,8 @@ export function App() {
107
107
  return c.live(board)
108
108
  }, [board])
109
109
 
110
- useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board])
110
+ const titles = useStore((s) => s.boardTitles)
111
+ useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board, titles])
111
112
 
112
113
  return (
113
114
  <div tabIndex={-1} className="sh-app dark">
@@ -168,6 +168,7 @@ export function PlayOverlay() {
168
168
  function BoardMenu({ current }: { current: string }) {
169
169
  const pop = usePopover()
170
170
  const [names, setNames] = useState<string[]>([current])
171
+ useStore((s) => s.boardTitles) // labels follow titles (the fetch below refreshes them)
171
172
  // refreshed on every open - agents create boards while you present
172
173
  useEffect(() => { if (pop.open) fetchBoardNames().then(setNames).catch(() => {}) }, [pop.open])
173
174
  return (
@@ -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" />)
@@ -4,7 +4,7 @@
4
4
  export const cap = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s)
5
5
 
6
6
  /** D5: derive a display label from a kebab slug - drop the dashes, Title Case each word.
7
- * For board/scene NAMES only (device/theme names are single words, cap is right there).
8
- * Acronyms ("tms" -> "Tms") await the board `title` override (C3); an explicit title,
9
- * once present, is shown verbatim and never passes through here. */
10
- export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|\s)\S/g, (c) => c.toUpperCase())
7
+ * For board/folder/scene NAMES only (device/theme names are single words, cap is right
8
+ * there). An explicit `title` (boards, folders, scenes - v1.6.1) is shown verbatim and never
9
+ * passes through here; `labelOf` in shared/board-tree.ts picks. */
10
+ export { humanize } from '../../shared/board-tree.ts'