@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.
@@ -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, boardLabel, fetchBoardTree, type TreeBase } from './store.ts'
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, isBoardName, isOwnSlot, moveBoard, newFolderSlot, renameFolder, resolveDrop, rootIndex, slugify,
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 (rename a board, rename a folder, name a new folder) ----
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
- 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
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
- // 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)
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 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
+ 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 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
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 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)
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
- setDrop(null)
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
- setDrop(dropAt(e.clientX, e.clientY, g.item))
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
- const target = dragged ? dropAt(e.clientX, e.clientY, item) : null
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>{boardLabel(n)}</span>
371
+ <span>{label(n)}</span>
345
372
  </button>
346
373
  )
347
374
  }
348
- const folderRow = (f: string, boards: string[], index: number): ReactNode[] => {
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="it folder editing">{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(f)}</div>)
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>{boardLabel(f)}</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>{boardLabel(draft.board)}</span></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>] : []),
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.name, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
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
- 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 (
@@ -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'
@@ -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
- return [...flatten((await fetchBoardTree()).tree), 'all-scenes']
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: the reserved 'all-scenes' key reads as "All scenes". */
165
- export const boardLabel = (n: string) => humanize(n)
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
- renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
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(from, to) {
682
+ renameBoard(name, title, baseHash) {
655
683
  return structural(async () => {
684
+ const from = name
656
685
  const active = from === get().board
657
- // Renaming the ACTIVE board renames its file out from under the autosave. Flush any
658
- // pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
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-rename must NOT save to the
664
- // old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
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, to }) }
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
- if (!res.ok) {
675
- const e = await res.json().catch(() => ({} as { error?: string }))
676
- return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
677
- }
678
- // Content is byte-identical after a file move, so boardHash still matches for the next
679
- // autosave; only the name (and the URL, via the board-change subscription) changes. Set
680
- // the new name BEFORE releasing the hold so a resumed save targets `to`.
681
- if (active && get().board === from) set({ board: to })
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 }