@marver-design/marver 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -4
  3. package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
  4. package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
  5. package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
  6. package/dist/cli.mjs +27 -16
  7. package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
  9. package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
  10. package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
  11. package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
  12. package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
  13. package/dist/shot-BzQ0PXKH.mjs +86 -0
  14. package/dist/shot-DlmTO8AF.mjs +926 -0
  15. package/docs/live-jam.md +20 -8
  16. package/docs/publish.md +2 -2
  17. package/package.json +1 -1
  18. package/src/client/shell/App.tsx +42 -14
  19. package/src/client/shell/BoardList.tsx +88 -58
  20. package/src/client/shell/LockedApp.tsx +2 -1
  21. package/src/client/shell/Play.tsx +1 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +76 -29
  24. package/src/client/shell/styles.css +5 -1
  25. package/src/shared/board-tree.ts +104 -46
  26. package/templates/AGENTS-embedded.md +13 -3
  27. package/templates/AGENTS-studio.md +13 -3
  28. package/templates/instructions/boards.md +27 -9
  29. package/templates/instructions/discover.md +13 -1
  30. package/templates/instructions/jam.md +15 -3
  31. package/templates/instructions/shape.md +2 -1
  32. package/dist/shot-By1AItpD.mjs +0 -30
  33. package/dist/shot-CwmHO5T4.mjs +0 -528
package/docs/live-jam.md CHANGED
@@ -89,19 +89,31 @@ resolves a thread; you do that after reviewing.
89
89
 
90
90
  The missing sense that no-shell used to cost - "does my frame actually RENDER?" - is a
91
91
  server capability instead, rendered in the machine's own headless Chrome (no bundled
92
- browser, CDP over Node's built-in WebSocket) and written as a PNG under
92
+ browser; CDP over Chrome's debugging pipe, so the browser lives exactly as long as the
93
+ shot) and written as a PNG under
93
94
  `design/.local/shots/`. Two transports reach it, because the no-shell jail rules out the
94
95
  obvious one:
95
96
 
96
97
  - **The file-drop inbox** (works for every agent, including Claude Code, which has no shell
97
98
  and whose WebFetch refuses localhost). The agent writes
98
- `design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}`; the dev
99
- server renders and writes `<slug>.result.json` with the PNG path or an error, which the
100
- agent Reads.
101
- - **`npx marver shot <frame> [--scale 1-4]`** / `GET /api/shot?frame=<id>&theme=<t>&scale=<n>`
102
- for humans and shell-ful agents - the same renderer, one line. Default 2x; `--scale 4` for a
103
- print-quality still (a slide comes back 5120×2880). A frame too tall for the asked scale steps
104
- down and says so in `note`; the file name carries the scale actually used (`…@4x.png`).
99
+ `design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}` - or
100
+ `{"scene":"<name>"}`, `{"frames":[...]}`, `{"all":true}` for a batch; the dev server renders
101
+ and writes `<slug>.result.json` with the PNG path or an error (a batch: `results`, one entry
102
+ per frame), which the agent Reads.
103
+ - **`npx marver shot <frame ...> | --scene <name> | --all [--scale 1-4] [--json]`** /
104
+ `GET /api/shot?frame=<id>&theme=<t>&scale=<n>` / `POST /api/shots {frames|scene|all, theme,
105
+ scale}` for humans and shell-ful agents - the same renderer, one line. A batch is ONE
106
+ operation: one headless browser, `MARVER_SHOT_CONCURRENCY` frames at a time inside it
107
+ (default up to 6, sized to the machine), so a scene costs about what a frame does. Default
108
+ 2x; `--scale 4` for a print-quality still (a slide comes back 5120×2880). A frame too tall
109
+ for the asked scale steps down and says so in `note`; the file name carries the scale
110
+ actually used (`…@4x.png`). A frame that ran out of settle budget still ships, marked
111
+ `unsettled` with a note.
112
+ - **The browser's life.** The headless Chrome exists only while an operation runs - it is
113
+ driven over Chrome's own debugging pipe, so it dies with the dev server however the server
114
+ dies (Ctrl-C, a closed terminal, `kill -9`), and none is kept between shots. `MARVER_CHROME`
115
+ picks the binary; pointing it at a Chrome for Testing or Chromium build makes the shot
116
+ browser a different app from your own, which some people prefer on macOS.
105
117
  The canvas's **copy as image** (`i` / `⇧i`, the images-square toolbar button) is this same
106
118
  renderer with `format=png`, so what a designer pastes and what an agent shoots is one picture.
107
119
 
package/docs/publish.md CHANGED
@@ -33,8 +33,8 @@ opens the same view on the published site, as long as its board and frame shippe
33
33
  directory in full (the `--boards` filter covers frames, not public assets). A flow you
34
34
  publish must have all its `data-goto` targets on a published board. Sidebar folders ship
35
35
  only around published boards (a folder with nothing published never reaches the bundle),
36
- and `description`s ship only for published things - the project's, the published boards',
37
- their folders', scenes' and frames'.
36
+ and `title`s and `description`s ship only for published things - the project's, the
37
+ published boards', their folders', scenes' and frames'.
38
38
 
39
39
  ## Who can open your canvas
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,5 +1,5 @@
1
1
  import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
2
- import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, type FrameEntry } from './store.ts'
2
+ import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, sceneLabel, type FrameEntry } from './store.ts'
3
3
  import { BoardList } from './BoardList.tsx'
4
4
  import { ContextMenu, copyToClipboard, framePath, useContextMenu, type MenuItem } from './ContextMenu.tsx'
5
5
  import { Tip } from './Tip.tsx'
@@ -8,7 +8,8 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
8
8
  import { frameByWindow } from './canvas/frame-registry.ts'
9
9
  import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
10
10
  import { bootHash, parseHash, writeHash } from './hash.ts'
11
- import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
+ import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
12
+ import { humanize as slugLabel, readTitle } from '../../shared/board-tree.ts'
12
13
  import { CommentsController, revealThread } from './Comments.tsx'
13
14
  import { poweredByUrl } from '../../shared/utm.ts'
14
15
  import { avatarFallback, useComments } from './comments-store.ts'
@@ -19,17 +20,31 @@ const commentsStore = () => useComments.getState()
19
20
  let booted = false // survives Fast Refresh; see the boot effect
20
21
 
21
22
  /** One collapsible scene group in the sidebar. `held` marks a scene that contains a
22
- * selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
23
- function SceneGroup({ name, count, held, onPick, onContextMenu, children }: { name: string; count: number; held: boolean; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
23
+ * selected frame - a quiet secondary wash so ancestry survives collapsing the group.
24
+ * `renaming` swaps the label for an input: the scene's TITLE (its brief's front matter) -
25
+ * the directory never moves from here. */
26
+ function SceneGroup({ name, label, count, held, renaming, onRename, onPick, onContextMenu, children }: { name: string; label: string; count: number; held: boolean; renaming?: boolean; onRename?: (title: string | null) => void; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
24
27
  const [open, setOpen] = useState(true)
28
+ const done = useRef(false) // Enter then blur: one commit
29
+ if (renaming) { done.current = false }
30
+ const finish = (v: string | null) => { if (done.current) return; done.current = true; onRename?.(v) }
25
31
  return (
26
32
  <div>
27
- <button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)} onContextMenu={onContextMenu}>
28
- <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
29
- {/* the NAME selects every frame in the scene; the caret/row still collapses */}
30
- <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{humanize(name) || '(root)'}</span>
31
- <small>{count}</small>
32
- </button>
33
+ {renaming ? (
34
+ <div className="it editing">
35
+ <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
36
+ <input autoFocus defaultValue={label} spellCheck={false} onFocus={(e) => e.currentTarget.select()}
37
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); finish(e.currentTarget.value) } else if (e.key === 'Escape') { e.preventDefault(); finish(null) } }}
38
+ onBlur={(e) => finish(e.currentTarget.value)} />
39
+ </div>
40
+ ) : (
41
+ <button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)} onContextMenu={onContextMenu}>
42
+ <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
43
+ {/* the NAME selects every frame in the scene; the caret/row still collapses */}
44
+ <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{label || '(root)'}</span>
45
+ <small>{count}</small>
46
+ </button>
47
+ )}
33
48
  {open && children}
34
49
  </div>
35
50
  )
@@ -325,6 +340,7 @@ export function App() {
325
340
  const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
326
341
  const [pillOpen, setPillOpen] = useState(true)
327
342
  const cm = useContextMenu() // shared sidebar right-click menu (boards + scenes)
343
+ const [sceneNaming, setSceneNaming] = useState<string | null>(null) // the scene whose title is being edited inline
328
344
 
329
345
  // boot honors the deep link: board before load, play mode after it.
330
346
  // Selection + camera intent are restored by the Canvas boot effect. The module-level
@@ -431,9 +447,10 @@ export function App() {
431
447
  return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
432
448
  }, [])
433
449
 
434
- // page title follows the open board
450
+ // page title follows the open board (and its title, when one is set)
435
451
  const board = useStore((s) => s.board)
436
- useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board])
452
+ const titles = useStore((s) => s.boardTitles)
453
+ useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board, titles])
437
454
 
438
455
  // favicon follows the mode: blue pack in design mode, purple pack in interact.
439
456
  // The links are rebuilt (not toggled) so the set stays deterministic; the .ico is
@@ -484,6 +501,8 @@ export function App() {
484
501
  useEffect(() => {
485
502
  if (!import.meta.hot) return
486
503
  import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
504
+ // a brief's title or description changed with the frames intact: relabel, never re-key
505
+ import.meta.hot.on('sh:scenes', (m: any) => useStore.getState().setScenes(m?.scenes))
487
506
  // Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
488
507
  // Camera-safe by construction - this only toggles a glow class, never moves the view.
489
508
  import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
@@ -779,15 +798,24 @@ export function App() {
779
798
  <BoardList onMenu={cm.open} />
780
799
  <div className="hd" style={{ marginTop: 10 }}>Scenes</div>
781
800
  {scenes.map((sc) => (
782
- <SceneGroup key={sc.name} name={sc.name} count={sc.frames}
801
+ <SceneGroup key={sc.name} name={sc.name} label={sceneLabel(sc.name)} count={sc.frames}
783
802
  held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
803
+ renaming={sceneNaming === sc.name}
804
+ onRename={(raw) => {
805
+ setSceneNaming(null)
806
+ const title = raw === null ? undefined : readTitle(raw)
807
+ if (!title || title === sceneLabel(sc.name)) return // Escape, empty, or unchanged = never mind
808
+ if (scenes.some((o) => o.name !== sc.name && sceneLabel(o.name) === title)) { toast(`a scene called "${title}" already exists`); return }
809
+ // typing what the directory reads as anyway clears the title - the brief stays clean
810
+ useStore.getState().renameScene(sc.name, title === slugLabel(sc.name) ? '' : title).then((r) => { if (!r.ok) toast(r.error ?? 'rename failed') })
811
+ }}
784
812
  onContextMenu={(e) => cm.open(e, [{
785
813
  label: 'Copy path',
786
814
  icon: <SignpostIcon size={15} />,
787
815
  onClick: () => copyToClipboard(sc.name
788
816
  ? `board: ${useStore.getState().board} · scene: ${sc.name} (design/scenes/${sc.name}/)`
789
817
  : `board: ${useStore.getState().board} · scene: (root)`, 'path copied'),
790
- }])}
818
+ }, ...(!PUBLISHED && sc.name ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setSceneNaming(sc.name) }] : [])])}
791
819
  onPick={() => {
792
820
  const keys = nodes.filter((n) => frames.some((f) => f.scene === sc.name && f.id === n.frame) && !n.missing).map((n) => n.key)
793
821
  if (!keys.length) return
@@ -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'