@marver-design/marver 0.16.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.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -2
- package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
- package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
- package/dist/{build-Ct0iMoSi.mjs → build-B4yPgFNF.mjs} +12 -8
- package/dist/cli.mjs +25 -14
- package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-CdcIuhZJ.mjs → dev-DH2W7Ffw.mjs} +4 -4
- package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
- package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
- package/dist/{plugin-BXyezwfN.mjs → plugin-BeBGu3gH.mjs} +85 -23
- package/dist/{poster-DOY7pax8.mjs → poster-CoyobbGW.mjs} +1 -1
- package/dist/{shot-CwmHO5T4.mjs → shot-z-d-zMzf.mjs} +2 -2
- package/docs/publish.md +2 -2
- package/package.json +1 -1
- package/src/client/shell/App.tsx +42 -14
- package/src/client/shell/BoardList.tsx +88 -58
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +76 -29
- package/src/client/shell/styles.css +5 -1
- package/src/shared/board-tree.ts +104 -46
- package/templates/AGENTS-embedded.md +9 -2
- package/templates/AGENTS-studio.md +9 -2
- package/templates/instructions/boards.md +27 -9
- package/templates/instructions/discover.md +13 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
|
-
import { useStore, HAS_ALL_SCENES, PUBLISHED,
|
|
2
|
+
import { useStore, HAS_ALL_SCENES, PUBLISHED, fetchBoardTree, rememberTitles, type TreeBase } from './store.ts'
|
|
3
3
|
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
5
|
import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
|
|
6
6
|
import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
|
|
7
7
|
import {
|
|
8
|
-
applyDrop, boardsIn, createFolder, deleteFolder, folderOf, foldersIn,
|
|
9
|
-
type Drag, type Drop, type TreeItem,
|
|
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
10
|
} from '../../shared/board-tree.ts'
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer preference, never data
|
|
23
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
24
|
|
|
26
25
|
/** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
|
|
27
26
|
* yet - drawn at root `index`, optionally with `board` already inside it. */
|
|
@@ -31,6 +30,8 @@ type Intent = (tree: TreeItem[]) => TreeItem[] | null
|
|
|
31
30
|
|
|
32
31
|
export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
33
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)
|
|
34
35
|
const [tree, setTree] = useState<TreeItem[]>([])
|
|
35
36
|
const [naming, setNaming] = useState<Naming | null>(null)
|
|
36
37
|
const [closed, setClosed] = useState<Record<string, true>>(readClosed)
|
|
@@ -50,7 +51,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
50
51
|
// drag runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
|
|
51
52
|
// the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
|
|
52
53
|
// 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 gestureRef = useRef<{ pointerId: number; startX: number; startY: number; item: Drag; dragging: boolean; el: HTMLElement; x: number; y: number } | null>(null)
|
|
54
55
|
const treeRef = useRef(tree)
|
|
55
56
|
treeRef.current = tree
|
|
56
57
|
const namingRef = useRef(naming) // commit reads the LIVE naming state, never a stale closure (Escape, then a late blur)
|
|
@@ -70,6 +71,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
70
71
|
if (seq !== loadSeq.current) return false // an older read landing late never overwrites a newer one
|
|
71
72
|
confirmedRef.current = snap.tree
|
|
72
73
|
baseRef.current = snap.base
|
|
74
|
+
rememberTitles(snap.titles) // the labels follow the same latest-wins rule
|
|
73
75
|
lastErr.current = ''
|
|
74
76
|
show()
|
|
75
77
|
return true
|
|
@@ -156,92 +158,117 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
// ---- inline naming (
|
|
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.
|
|
160
168
|
const commit = async (raw: string) => {
|
|
161
169
|
const n = namingRef.current
|
|
162
170
|
if (!n || commitBusy.current) return // Enter already fired this (or Escape cancelled); ignore the follow-up blur
|
|
163
171
|
commitBusy.current = true
|
|
164
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)
|
|
165
178
|
if (n.kind === 'board') {
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
|
|
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()
|
|
174
186
|
return
|
|
175
187
|
}
|
|
176
|
-
|
|
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)
|
|
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))
|
|
183
189
|
if (n.kind === 'folder') {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
190
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')
|
|
191
204
|
} finally { commitBusy.current = false }
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
// ---- drag and drop: one pointer gesture for board rows and folder rows ----
|
|
195
|
-
/** The
|
|
196
|
-
|
|
197
|
-
const
|
|
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
|
|
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) => {
|
|
200
211
|
const r = el.getBoundingClientRect()
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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)
|
|
210
222
|
return target && !isOwnSlot(treeRef.current, d, target) ? target : null
|
|
211
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) }
|
|
212
226
|
const resetPointer = () => {
|
|
213
227
|
const g = gestureRef.current
|
|
214
228
|
gestureRef.current = null
|
|
215
229
|
if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
|
|
216
230
|
document.body.classList.remove('sh-board-dragging')
|
|
217
231
|
setDrag(null)
|
|
218
|
-
|
|
232
|
+
showDrop(null)
|
|
219
233
|
}
|
|
220
234
|
const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, item: Drag) => {
|
|
221
235
|
if (e.button !== 0) return // left button only; right-click opens the menu
|
|
222
236
|
const el = e.currentTarget
|
|
223
237
|
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 }
|
|
238
|
+
gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, item, dragging: false, el, x: e.clientX, y: e.clientY }
|
|
225
239
|
}
|
|
226
240
|
const onPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
227
241
|
const g = gestureRef.current
|
|
228
242
|
if (!g || g.pointerId !== e.pointerId) return
|
|
243
|
+
g.x = e.clientX; g.y = e.clientY
|
|
229
244
|
if (!g.dragging) {
|
|
230
245
|
if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
|
|
231
246
|
g.dragging = true
|
|
232
247
|
setDrag(g.item)
|
|
233
248
|
document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
|
|
234
249
|
}
|
|
235
|
-
|
|
250
|
+
showDrop(dropAt(e.clientX, e.clientY, g.item))
|
|
236
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])
|
|
237
259
|
const onPointerUp = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
238
260
|
const g = gestureRef.current
|
|
239
261
|
if (!g || g.pointerId !== e.pointerId) return
|
|
240
262
|
// 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)
|
|
263
|
+
// up (capture can be lost, a refresh can re-key the dragged row); the target is the seam
|
|
264
|
+
// the human saw
|
|
242
265
|
const item = g.item
|
|
243
266
|
const dragged = g.dragging
|
|
244
|
-
|
|
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
|
|
245
272
|
resetPointer()
|
|
246
273
|
if (dragged) {
|
|
247
274
|
if (!target) return
|
|
@@ -290,7 +317,6 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
290
317
|
}
|
|
291
318
|
const onMenuRef = useRef(onMenu)
|
|
292
319
|
onMenuRef.current = onMenu
|
|
293
|
-
const rootRef = useRef<HTMLDivElement>(null)
|
|
294
320
|
useEffect(() => {
|
|
295
321
|
const panel = rootRef.current?.closest('.sh-panel')
|
|
296
322
|
const scroll = rootRef.current?.closest('.sh-panel-scroll')
|
|
@@ -320,13 +346,14 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
320
346
|
return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
|
|
321
347
|
}
|
|
322
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
|
|
323
352
|
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>
|
|
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>
|
|
325
354
|
)
|
|
326
355
|
const canDrag = !PUBLISHED && n !== 'all-scenes'
|
|
327
356
|
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
357
|
return (
|
|
331
358
|
<button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
|
|
332
359
|
className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
|
|
@@ -341,22 +368,25 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
341
368
|
onPointerCancel={canDrag ? () => resetPointer() : undefined}
|
|
342
369
|
onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
343
370
|
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
344
|
-
<span>{
|
|
371
|
+
<span>{label(n)}</span>
|
|
345
372
|
</button>
|
|
346
373
|
)
|
|
347
374
|
}
|
|
348
|
-
const folderRow = (
|
|
375
|
+
const folderRow = (it: Folder, boards: string[], index: number): ReactNode[] => {
|
|
376
|
+
const f = it.name
|
|
349
377
|
const open = !closed[f]
|
|
350
378
|
const rows: ReactNode[] = []
|
|
351
379
|
if (naming?.kind === 'folder' && naming.name === f) {
|
|
352
|
-
rows.push(<div key={`f:${f}`} className=
|
|
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>)
|
|
353
381
|
} else {
|
|
354
382
|
const item: Drag = { kind: 'folder', name: f }
|
|
355
383
|
const dragging = drag?.kind === 'folder' && drag.name === f
|
|
356
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
|
|
357
387
|
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)}`}
|
|
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)}`}
|
|
360
390
|
onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
|
|
361
391
|
onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
|
|
362
392
|
onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
|
|
@@ -365,7 +395,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
365
395
|
onPointerCancel={!PUBLISHED ? () => resetPointer() : undefined}
|
|
366
396
|
onLostPointerCapture={!PUBLISHED ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
367
397
|
{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}
|
|
368
|
-
<span>{
|
|
398
|
+
<span>{labelOf(f, it.title)}</span>
|
|
369
399
|
<small>{boards.length}</small>
|
|
370
400
|
</button>,
|
|
371
401
|
)
|
|
@@ -380,12 +410,12 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
380
410
|
const rows: ReactNode[] = []
|
|
381
411
|
const draftRows = draft ? [
|
|
382
412
|
<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>{
|
|
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>] : []),
|
|
384
414
|
] : []
|
|
385
415
|
tree.forEach((it, i) => {
|
|
386
416
|
if (draft && draft.index === i) rows.push(...draftRows)
|
|
387
417
|
if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
|
|
388
|
-
else rows.push(...folderRow(it
|
|
418
|
+
else rows.push(...folderRow(it, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
|
|
389
419
|
})
|
|
390
420
|
if (draft && draft.index >= tree.length) rows.push(...draftRows)
|
|
391
421
|
if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
|
|
@@ -107,7 +107,8 @@ export function App() {
|
|
|
107
107
|
return c.live(board)
|
|
108
108
|
}, [board])
|
|
109
109
|
|
|
110
|
-
|
|
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 (
|
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
export
|
|
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'
|
|
@@ -14,6 +14,8 @@ const DATA: {
|
|
|
14
14
|
manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
|
|
15
15
|
/** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */
|
|
16
16
|
tree?: TreeItem[]
|
|
17
|
+
/** slug → title of the published boards (0.16.1 bundles); folder titles ride on `tree` */
|
|
18
|
+
titles?: Record<string, string>
|
|
17
19
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
18
20
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
19
21
|
} | null = shData
|
|
@@ -44,7 +46,7 @@ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean }
|
|
47
|
-
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
49
|
+
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string }[] }
|
|
48
50
|
export interface Node {
|
|
49
51
|
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
50
52
|
/** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
|
|
@@ -125,23 +127,23 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
125
127
|
export { cap, humanize } from './labels.ts'
|
|
126
128
|
import { cap, humanize } from './labels.ts'
|
|
127
129
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
128
|
-
import { buildTree, flatten, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
130
|
+
import { buildTree, flatten, labelOf, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
129
131
|
|
|
130
132
|
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
131
133
|
* the folder registry (null = there was no file). */
|
|
132
134
|
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
133
|
-
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase }
|
|
135
|
+
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
|
|
134
136
|
|
|
135
137
|
/** The sidebar tree: root boards and folders in rank order, each folder's boards inside
|
|
136
138
|
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
137
139
|
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
138
140
|
* (the server's 422 message) - callers keep their last known tree. */
|
|
139
141
|
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
140
|
-
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null } }
|
|
142
|
+
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null }, titles: DATA.titles ?? {} }
|
|
141
143
|
const [boards, reg] = await Promise.all([
|
|
142
|
-
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string }[]>,
|
|
144
|
+
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
|
|
143
145
|
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
144
|
-
const j = await r.json() as { folders?: { name: string; order?: number }[]; sha256?: string | null; error?: string }
|
|
146
|
+
const j = await r.json() as { folders?: { name: string; order?: number; title?: string }[]; sha256?: string | null; error?: string }
|
|
145
147
|
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
146
148
|
return j
|
|
147
149
|
}),
|
|
@@ -149,20 +151,32 @@ export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
|
149
151
|
return {
|
|
150
152
|
tree: buildTree(boards, reg.folders ?? []),
|
|
151
153
|
base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
|
|
154
|
+
titles: Object.fromEntries(boards.flatMap((b) => (b.title ? [[b.name, b.title]] : []))),
|
|
152
155
|
}
|
|
153
156
|
}
|
|
157
|
+
/** A tree read's board titles become the labels' - called by the reader once it knows the
|
|
158
|
+
* read is its latest (an older response must not overwrite a newer one's labels). Folder
|
|
159
|
+
* titles ride on the tree's folder items: a board and a folder may share a slug, never one map. */
|
|
160
|
+
export function rememberTitles(titles: Record<string, string>) {
|
|
161
|
+
if (JSON.stringify(useStore.getState().boardTitles) !== JSON.stringify(titles)) useStore.setState({ boardTitles: titles })
|
|
162
|
+
}
|
|
154
163
|
|
|
155
164
|
/** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
|
|
156
165
|
* the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
157
166
|
* Throws on transport failure - callers keep their last known list. */
|
|
158
167
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
159
168
|
if (DATA) return DATA.names
|
|
160
|
-
|
|
169
|
+
const snap = await fetchBoardTree()
|
|
170
|
+
rememberTitles(snap.titles)
|
|
171
|
+
return [...flatten(snap.tree), 'all-scenes']
|
|
161
172
|
}
|
|
162
173
|
/** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
|
|
163
174
|
export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
|
|
164
|
-
/** Display name for a board:
|
|
165
|
-
|
|
175
|
+
/** Display name for a board: its title when one is set (subscribe to `boardTitles` to
|
|
176
|
+
* re-render on change), else the Title-Cased slug ('all-scenes' reads "All Scenes"). */
|
|
177
|
+
export const boardLabel = (n: string) => labelOf(n, useStore.getState().boardTitles[n])
|
|
178
|
+
/** Display name for a scene: the title in its brief's front matter, else the Title-Cased directory. */
|
|
179
|
+
export const sceneLabel = (n: string) => labelOf(n, useStore.getState().manifest?.scenes.find((s) => s.name === n)?.title)
|
|
166
180
|
|
|
167
181
|
/** The double-submit CSRF token the owner gate wants echoed (same read as comments-store). */
|
|
168
182
|
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
|
|
@@ -208,6 +222,8 @@ const initialViewTheme = () => {
|
|
|
208
222
|
return CONFIG.themes[0] ?? 'light'
|
|
209
223
|
}
|
|
210
224
|
const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
|
|
225
|
+
let scenesRev = 0 // bumps per sh:scenes, so a load that straddled one keeps the live labels
|
|
226
|
+
let liveScenes: Manifest['scenes'] | null = null // the last sh:scenes payload - applied late when it beat the first manifest
|
|
211
227
|
|
|
212
228
|
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design:
|
|
213
229
|
* auto sizes are never serialized - a reload remeasures. */
|
|
@@ -304,7 +320,16 @@ interface State {
|
|
|
304
320
|
setDeviceView(name: string | null): void
|
|
305
321
|
resizeSelected(name: string | null): void
|
|
306
322
|
switchBoard(name: string): Promise<void>
|
|
307
|
-
|
|
323
|
+
/** what the sidebar labels boards by: slug → title, off the last tree read */
|
|
324
|
+
boardTitles: Record<string, string>
|
|
325
|
+
/** retitle a board - what humans see ('' = back to the Title-Cased slug). The file never
|
|
326
|
+
* moves: its name is the board's identity (agents, publish.json, URLs, comment threads).
|
|
327
|
+
* `baseHash` = the file as last seen; a 409 (`stale`) means someone wrote it since. */
|
|
328
|
+
renameBoard(name: string, title: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
|
|
329
|
+
/** a scene's title, into its brief's front matter (the directory never moves) */
|
|
330
|
+
renameScene(scene: string, title: string): Promise<{ ok: boolean; error?: string }>
|
|
331
|
+
/** `sh:scenes`: the scenes changed (a brief's title or description) with the frames intact */
|
|
332
|
+
setScenes(scenes: Manifest['scenes']): void
|
|
308
333
|
arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
|
|
309
334
|
pulsePath(): void
|
|
310
335
|
copyFrameImage(scale: 2 | 4): void
|
|
@@ -601,13 +626,13 @@ export const useStore = create<State>((set, get) => {
|
|
|
601
626
|
return {
|
|
602
627
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
603
628
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
604
|
-
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
|
|
629
|
+
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {},
|
|
605
630
|
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
|
|
606
631
|
|
|
607
632
|
async boot() {
|
|
608
633
|
const seq = ++loadSeq
|
|
609
634
|
const boardName = get().board
|
|
610
|
-
const revAtStart = editRev
|
|
635
|
+
const revAtStart = editRev, scenesAtStart = scenesRev
|
|
611
636
|
const next = await loadBoardState(boardName)
|
|
612
637
|
if (seq !== loadSeq) return false // a newer load superseded this one
|
|
613
638
|
if (!next) { get().toast(`board "${boardName}" failed to load`); return false }
|
|
@@ -617,11 +642,13 @@ export const useStore = create<State>((set, get) => {
|
|
|
617
642
|
set(next)
|
|
618
643
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
619
644
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
645
|
+
else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) // an sh:scenes that landed mid-fetch outranks the file we read
|
|
620
646
|
return true
|
|
621
647
|
},
|
|
622
648
|
|
|
623
649
|
async switchBoard(name) {
|
|
624
650
|
const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
|
|
651
|
+
const scenesAtStart = scenesRev
|
|
625
652
|
if (name === get().board) return
|
|
626
653
|
// a resize held across a switch: settle THIS board (retidy if it has a recipe)
|
|
627
654
|
// before the flush, so the torn mid-gesture state is never what reaches disk
|
|
@@ -649,41 +676,61 @@ export const useStore = create<State>((set, get) => {
|
|
|
649
676
|
set({ board: name, interact: null, ...next })
|
|
650
677
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
651
678
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
679
|
+
else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } })
|
|
652
680
|
},
|
|
653
681
|
|
|
654
|
-
renameBoard(
|
|
682
|
+
renameBoard(name, title, baseHash) {
|
|
655
683
|
return structural(async () => {
|
|
684
|
+
const from = name
|
|
656
685
|
const active = from === get().board
|
|
657
|
-
//
|
|
658
|
-
//
|
|
686
|
+
// Retitling the ACTIVE board rewrites its file under the autosave. Flush any pending
|
|
687
|
+
// write FIRST (switchBoard's pattern), so a PUT never races the rewrite.
|
|
659
688
|
if (active) {
|
|
660
689
|
let ok = true
|
|
661
690
|
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
662
691
|
if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
|
|
663
|
-
// hold autosave across the round-trip: an edit landing mid-
|
|
664
|
-
//
|
|
692
|
+
// hold autosave across the round-trip: an edit landing mid-write would PUT against a
|
|
693
|
+
// hash the rewrite is about to move
|
|
665
694
|
holdSaves()
|
|
666
695
|
}
|
|
667
|
-
// Release the hold and resume autosave. Re-read the LIVE board, not the captured `active`:
|
|
668
|
-
// a board switch may have completed while we awaited, so only the board that is STILL
|
|
669
|
-
// `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
|
|
670
696
|
try {
|
|
697
|
+
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
698
|
+
// the sidebar looked); every other board's is the sidebar's
|
|
699
|
+
const base = active && get().boardHash ? get().boardHash : baseHash
|
|
671
700
|
let res: Response
|
|
672
|
-
try { res = await postOwner('boards/rename', { from,
|
|
701
|
+
try { res = await postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) }) }
|
|
673
702
|
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
703
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
|
|
704
|
+
if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `rename failed (${res.status})` }
|
|
705
|
+
// the write rewrote the file: the active board's CAS token moves to the hash the server
|
|
706
|
+
// answered (else the next autosave 409s against our own write) - before releasing the hold
|
|
707
|
+
if (active && get().board === from && body?.sha256) set({ boardHash: body.sha256 })
|
|
708
|
+
const titles = { ...get().boardTitles } // the label changes now; the next tree read confirms it from the file
|
|
709
|
+
if (title) titles[from] = title; else delete titles[from]
|
|
710
|
+
set({ boardTitles: titles })
|
|
682
711
|
return { ok: true }
|
|
683
712
|
} finally { if (active) releaseSaves() } // whatever happened, autosave resumes
|
|
684
713
|
})
|
|
685
714
|
},
|
|
686
715
|
|
|
716
|
+
async renameScene(scene, title) {
|
|
717
|
+
let res: Response
|
|
718
|
+
try { res = await postOwner('scenes/rename', { scene, title }) }
|
|
719
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
720
|
+
if (!res.ok) { const e = await res.json().catch(() => ({} as { error?: string })); return { ok: false, error: e?.error ?? `rename failed (${res.status})` } }
|
|
721
|
+
// the label changes now; the watcher's sh:scenes confirms it from the file
|
|
722
|
+
const m = get().manifest
|
|
723
|
+
if (m) set({ manifest: { ...m, scenes: m.scenes.map((s) => (s.name === scene ? { ...s, ...(title ? { title } : {}), ...(title ? {} : { title: undefined }) } : s)) } })
|
|
724
|
+
return { ok: true }
|
|
725
|
+
},
|
|
726
|
+
setScenes(scenes) {
|
|
727
|
+
if (!Array.isArray(scenes)) return
|
|
728
|
+
scenesRev++
|
|
729
|
+
liveScenes = scenes
|
|
730
|
+
const m = get().manifest
|
|
731
|
+
if (m) set({ manifest: { ...m, scenes } }) // before the first manifest: kept for the boot's commit
|
|
732
|
+
},
|
|
733
|
+
|
|
687
734
|
// The whole sidebar tree in one write: order, membership, the folders themselves. The
|
|
688
735
|
// write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
|
|
689
736
|
// rename: flush the pending autosave first, hold autosave across the round-trip, then
|
|
@@ -526,8 +526,12 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
526
526
|
.sh-panel .it.folder.drop-into svg { color: #0088ff }
|
|
527
527
|
.sh-panel .it.board.in-folder { padding-left: 28px }
|
|
528
528
|
.sh-panel .it.board.draft { cursor: default; opacity: .6 }
|
|
529
|
-
/* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike
|
|
529
|
+
/* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike;
|
|
530
|
+
an open EMPTY folder draws that indented seam under its own header (drop-in) */
|
|
530
531
|
.sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
|
|
532
|
+
.sh-panel .it.folder.drop-in { position: relative }
|
|
533
|
+
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: 28px; right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
|
|
534
|
+
background: #0088ff; pointer-events: none }
|
|
531
535
|
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
532
536
|
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|
|
533
537
|
.sh-panel .sh-boards .hd span { flex: 1 }
|