@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
@@ -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 }
@@ -17,11 +17,11 @@ export const FOLDERS_FILE = '_folders.json'
17
17
  * off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
18
18
  export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
19
19
 
20
- export type Folder = { kind: 'folder'; name: string; boards: string[]; description?: string }
20
+ export type Folder = { kind: 'folder'; name: string; boards: string[]; title?: string; description?: string }
21
21
  export type TreeItem = { kind: 'board'; name: string } | Folder
22
22
 
23
- export interface BoardRow { name: string; order?: number; folder?: string }
24
- export interface FolderRow { name: string; order?: number; description?: string }
23
+ export interface BoardRow { name: string; order?: number; folder?: string; title?: string }
24
+ export interface FolderRow { name: string; order?: number; title?: string; description?: string }
25
25
 
26
26
  /** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
27
27
  export const DESCRIPTION_MAX = 300
@@ -31,7 +31,24 @@ export const readDescription = (v: unknown): string | undefined => {
31
31
  return s || undefined
32
32
  }
33
33
 
34
+ /** A title off a file - what humans see, free text: any casing, punctuation, emoji. Control
35
+ * characters dropped, whitespace collapsed, capped in code points (never half a surrogate
36
+ * pair). Absent when empty or not a string; the display then falls back to the slug. */
37
+ export const TITLE_MAX = 120
38
+ export const readTitle = (v: unknown): string | undefined => {
39
+ if (typeof v !== 'string') return undefined
40
+ // eslint-disable-next-line no-control-regex
41
+ const s = Array.from(v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().replace(/\s+/g, ' ')).slice(0, TITLE_MAX).join('').trim()
42
+ return s || undefined
43
+ }
44
+ /** Title Case off a slug - the display when no title is set ("old-stuff" → "Old Stuff"). */
45
+ export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|\s)\S/g, (c) => c.toUpperCase())
46
+ /** What a board, folder or scene is called on screen: its title, else its humanized slug. */
47
+ export const labelOf = (name: string, title?: string): string => title ?? humanize(name)
48
+
34
49
  const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
50
+ /** A folder's title and description, present only when set. */
51
+ const folderExtras = (it: { title?: string; description?: string }) => ({ ...(it.title ? { title: it.title } : {}), ...(it.description ? { description: it.description } : {}) })
35
52
 
36
53
  /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
37
54
  * malformed registry is an ERROR the human must fix (silently reading it as empty would
@@ -49,19 +66,22 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
49
66
  if (seen.has(name)) return `folder "${name}" is listed twice`
50
67
  seen.add(name)
51
68
  const o = (f as { order?: unknown }).order
69
+ const t = readTitle((f as { title?: unknown }).title)
52
70
  const d = readDescription((f as { description?: unknown }).description)
53
- out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(d ? { description: d } : {}) })
71
+ out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(t ? { title: t } : {}), ...(d ? { description: d } : {}) })
54
72
  }
55
73
  return out
56
74
  }
57
75
 
58
76
  /** Sidebar order from the files. Root: boards with no folder + every folder (registered
59
77
  * or implied by a board), ranked by `order` then kind (board before folder) then name.
60
- * Inside a folder: its boards by `order` then name. Unranked sorts after ranked. */
78
+ * Inside a folder: its boards by `order` then name. Unranked sorts after ranked. A folder's
79
+ * title and description ride on its item (they live in the registry a tree write rewrites);
80
+ * a board's title stays with its row - it lives in the board's own file. */
61
81
  export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
62
82
  const folderOrder = new Map<string, number | undefined>()
63
- const folderDesc = new Map<string, string>()
64
- for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order); if (f.description) folderDesc.set(f.name, f.description) }
83
+ const folderMeta = new Map<string, { title?: string; description?: string }>()
84
+ for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order); folderMeta.set(f.name, folderExtras(f)) }
65
85
  const members = new Map<string, BoardRow[]>()
66
86
  const rootBoards: BoardRow[] = []
67
87
  for (const b of boards) {
@@ -78,7 +98,7 @@ export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[]
78
98
  const root: Root[] = [
79
99
  ...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
80
100
  ...[...folderOrder].map(([name, order]) => ({
81
- item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderDesc.has(name) ? { description: folderDesc.get(name) } : {}) } as TreeItem,
101
+ item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderMeta.get(name) ?? {}) } as TreeItem,
82
102
  order,
83
103
  })),
84
104
  ]
@@ -94,12 +114,14 @@ export function flatten(tree: TreeItem[]): string[] {
94
114
  }
95
115
 
96
116
  /** The wire shape of a tree write (`POST boards/reorder`): plain strings for root boards,
97
- * `{ folder, boards }` for folders - what the sidebar posts and what the server validates. */
98
- export type WireItem = string | { folder: string; boards: string[]; description?: string }
117
+ * `{ folder, boards }` for folders - what the sidebar posts and what the server validates.
118
+ * A folder's title and description ride with it (they live in the registry the write
119
+ * rewrites); a board's title lives in its own file and never rides the tree. */
120
+ export type WireItem = string | { folder: string; boards: string[]; title?: string; description?: string }
99
121
  export const toWire = (tree: TreeItem[]): WireItem[] =>
100
- tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...(it.description ? { description: it.description } : {}) }))
122
+ tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...folderExtras(it) }))
101
123
  export const fromWire = (wire: WireItem[]): TreeItem[] =>
102
- wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...(w.description ? { description: w.description } : {}) }))
124
+ wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...folderExtras(w) }))
103
125
 
104
126
  /** Validate a wire tree off the network. Returns the error, or null when it is sound:
105
127
  * every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
@@ -118,8 +140,9 @@ export function validateWire(wire: unknown): string | null {
118
140
  for (const w of wire) {
119
141
  if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
120
142
  if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
121
- const { folder, boards: kids, description } = w as { folder?: unknown; boards?: unknown; description?: unknown }
143
+ const { folder, boards: kids, title, description } = w as { folder?: unknown; boards?: unknown; title?: unknown; description?: unknown }
122
144
  if (!isBoardName(folder)) return 'invalid folder name in tree'
145
+ if (title !== undefined && (typeof title !== 'string' || Array.from(title).length > TITLE_MAX)) return 'invalid folder title'
123
146
  if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
124
147
  if (folders.has(folder)) return `folder "${folder}" appears twice`
125
148
  folders.add(folder)
@@ -163,35 +186,59 @@ export function takeBoard(t: TreeItem[], board: string): { list: string | null;
163
186
  export type Drag = { kind: TreeItem['kind']; name: string }
164
187
  export type Drop = { list: string | null; index: number } | { into: string }
165
188
 
166
- /** The row under the pointer, measured by the DOM and handed here as plain facts. */
167
- export interface Hit {
168
- kind: TreeItem['kind']; name: string
169
- parent: string | null // the folder a board row lives in
170
- below: boolean // pointer in the lower half of the row
171
- topEdge: boolean // pointer in the top 30% of the row (folder rows: "before me")
172
- gutter: boolean // pointer left of a child row's indent (the root gutter = outdent)
173
- }
189
+ /** A row the sidebar rendered, measured - the list as the human sees it, top to bottom,
190
+ * the pinned `all-scenes` row included (it is the root's end). `open` is the folder's
191
+ * disclosure; `left` is the row's left edge, from which the child indent is measured. */
192
+ export interface Row { kind: TreeItem['kind']; name: string; parent: string | null; open?: boolean; top: number; bottom: number; left: number }
193
+ /** Px a board row indents inside a folder; the seams inside draw from there too. */
194
+ export const INDENT = 28
174
195
 
175
- /** The drop target for a drag over a row, or null. Boards land in any slot - root or inside
176
- * a folder - or INTO a folder; folders land in root slots only. Over a folder's child row the
177
- * root gutter means "after that folder" (the natural outdent). `all-scenes` = root end. */
178
- export function resolveDrop(t: TreeItem[], d: Drag, hit: Hit): Drop | null {
179
- if (hit.kind === 'folder') {
180
- const fi = rootIndex(t, 'folder', hit.name)
181
- if (fi < 0) return null
182
- if (d.kind === 'folder') return { list: null, index: hit.below ? fi + 1 : fi }
183
- return hit.topEdge ? { list: null, index: fi } : { into: hit.name } // top edge = before; the rest = inside
196
+ /** The drop target for a pointer at (x, y) over the rendered rows - never null inside the
197
+ * list (the ends clamp), so a release anywhere over the sidebar lands somewhere the seam
198
+ * showed. Boards: the middle band of a folder header (a closed one: its lower band too) is
199
+ * INTO it; otherwise the nearest gap by row midlines, read from the row above it: after a
200
+ * root board or a closed folder = root; after an open folder's header, or between two of
201
+ * its boards = inside; after a folder's LAST board (or under an open empty folder) the gap
202
+ * is shared between the folder and the root, and the row under the pointer decides: still
203
+ * over that last board (that header), at or right of the indent the seams draw at = inside;
204
+ * over the root row below it, or in the gutter = root, after the folder. Folders: root gaps
205
+ * only, each root item one block. */
206
+ export function resolveDrop(t: TreeItem[], d: Drag, rows: Row[], x: number, y: number): Drop | null {
207
+ const mid = (r: { top: number; bottom: number }) => (r.top + r.bottom) / 2
208
+ if (d.kind === 'folder') {
209
+ const blocks: { top: number; bottom: number }[] = []
210
+ for (const r of rows) {
211
+ if (r.parent && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
212
+ blocks.push({ top: r.top, bottom: r.bottom })
213
+ }
214
+ let g = 0
215
+ for (const b of blocks) if (y >= mid(b)) g++
216
+ return { list: null, index: Math.min(g, t.length) }
217
+ }
218
+ for (const r of rows) {
219
+ if (r.kind !== 'folder' || y < r.top || y >= r.bottom) continue
220
+ const f = (y - r.top) / (r.bottom - r.top)
221
+ if (f >= 0.25 && (f < 0.75 || !r.open)) return { into: r.name }
184
222
  }
185
- if (hit.name === 'all-scenes') return { list: null, index: t.length } // over the pinned last row = root end slot
186
- if (hit.parent) {
187
- const pi = rootIndex(t, 'folder', hit.parent)
188
- if (pi < 0) return null
189
- if (d.kind === 'folder' || hit.gutter) return { list: null, index: pi + 1 } // after that folder
190
- const i = folderIn(t, hit.parent)?.boards.indexOf(hit.name) ?? -1
191
- return i < 0 ? null : { list: hit.parent, index: hit.below ? i + 1 : i }
223
+ let g = 0
224
+ for (const r of rows) if (y >= mid(r)) g++
225
+ const above = rows[g - 1], below = rows[g]
226
+ if (!above) return { list: null, index: 0 }
227
+ if (above.name === 'all-scenes') return { list: null, index: t.length }
228
+ const inside = (folder: string, index: number, alt: Drop): Drop => (y < above.bottom && x >= above.left + INDENT ? { list: folder, index } : alt)
229
+ if (above.kind === 'board') {
230
+ if (!above.parent) { const i = rootIndex(t, 'board', above.name); return i < 0 ? null : { list: null, index: i + 1 } }
231
+ const fi = rootIndex(t, 'folder', above.parent)
232
+ const i = folderIn(t, above.parent)?.boards.indexOf(above.name) ?? -1
233
+ if (fi < 0 || i < 0) return null
234
+ if (below?.kind === 'board' && below.parent === above.parent) return { list: above.parent, index: i + 1 }
235
+ return inside(above.parent, i + 1, { list: null, index: fi + 1 })
192
236
  }
193
- const i = rootIndex(t, 'board', hit.name)
194
- return i < 0 ? null : { list: null, index: hit.below ? i + 1 : i }
237
+ const fi = rootIndex(t, 'folder', above.name)
238
+ if (fi < 0) return null
239
+ if (!above.open) return { list: null, index: fi + 1 }
240
+ if (below?.kind === 'board' && below.parent === above.name) return { list: above.name, index: 0 }
241
+ return inside(above.name, 0, { list: null, index: fi + 1 })
195
242
  }
196
243
 
197
244
  /** A target that would leave the item where it is: nothing to show, nothing to drop. Into
@@ -248,25 +295,36 @@ export function moveBoard(tree: TreeItem[], board: string, folder: string | null
248
295
  }
249
296
 
250
297
  /** A new folder at root `index`, holding `board` (pulled from wherever it sat) when given. */
251
- export function createFolder(tree: TreeItem[], name: string, index: number, board?: string): TreeItem[] | null {
298
+ export function createFolder(tree: TreeItem[], name: string, index: number, board?: string, title?: string): TreeItem[] | null {
252
299
  if (foldersIn(tree).includes(name)) return null
253
300
  const next = cloneTree(tree)
254
301
  const boards: string[] = []
255
302
  if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
256
- next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards })
303
+ next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards, ...(title ? { title } : {}) })
257
304
  return next
258
305
  }
259
306
 
260
- export function renameFolder(tree: TreeItem[], from: string, to: string): TreeItem[] | null {
261
- if (from === to) return cloneTree(tree)
262
- if (foldersIn(tree).includes(to)) return null
307
+ /** Retitle a folder: what humans see (''= clear, back to the Title-Cased slug). The slug is
308
+ * its identity - `folder:` on every member board, the registry key - and never changes here. */
309
+ export function retitleFolder(tree: TreeItem[], name: string, title: string): TreeItem[] | null {
263
310
  const next = cloneTree(tree)
264
- const f = folderIn(next, from)
311
+ const f = folderIn(next, name)
265
312
  if (!f) return null
266
- f.name = to
313
+ if (title) f.title = title; else delete f.title
267
314
  return next
268
315
  }
269
316
 
317
+ /** The slug a NEW folder gets from the title the human typed: `slugify(title)`, then `-2`,
318
+ * `-3`, ... past a taken one; `fallback` (and `fallback-2`, ...) when nothing survives
319
+ * slugifying ("🚀"). Slugs are minted once - a rename changes the title, never the slug. */
320
+ export function slugFor(title: string, taken: string[], fallback = 'folder'): string {
321
+ const s = slugify(title)
322
+ if (s && !taken.includes(s)) return s
323
+ const base = s || fallback
324
+ if (!s && !taken.includes(base)) return base
325
+ for (let i = 2; ; i++) { const c = `${base.slice(0, NAME_MAX - 1 - String(i).length)}-${i}`; if (!taken.includes(c)) return c }
326
+ }
327
+
270
328
  /** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
271
329
  export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
272
330
  const next = cloneTree(tree)
@@ -81,7 +81,10 @@ planning. The human should see the request land on the canvas within the first m
81
81
  lit frame, never before one.
82
82
  3. Build. Independent frames can go in parallel - one subagent per frame, each marking
83
83
  its own; frames that depend on one another go in order.
84
- 4. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
84
+ 4. **Look before you say done**: `npx marver shot --scene <scene>` (or `<scene/frame ...>`,
85
+ `--all`) renders the frames headless in one go - one PNG path per line - and you READ
86
+ the PNGs. No shell? instructions/jam.md has the file-drop way (`{"scene":"..."}`).
87
+ 5. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
85
88
  self-expire (default 10 min; `--ttl <min>` up to 30) - re-run `start` on long jobs,
86
89
  and never lean on expiry instead of `done`.
87
90
 
@@ -133,8 +136,15 @@ Report where the request came from: chat requests get chat replies; only comment
133
136
  ## Orientation
134
137
  - design/manifest.json is the canvas with its purpose: the project (name, description),
135
138
  every folder and board in sidebar order, every scene and frame - each with its
136
- `description` when one was written. Read it before exploring; `marver dev` keeps it
137
- fresh. `npx marver boards` prints the boards part as a tree.
139
+ `description` when one was written, and its `title` when the human (or you) named it.
140
+ Read it before exploring; `marver dev` keeps it fresh. `npx marver boards` prints the
141
+ boards part as a tree.
142
+ - **Names vs titles.** A board's file name, a folder's `name`, a scene's directory are
143
+ IDENTITIES: slugs you address, that publish.json, URLs and comment threads hold, and
144
+ that never move on a rename. `title` is what humans see - free text (casing,
145
+ punctuation, emoji) on the board JSON, the `_folders.json` entry, the brief's front
146
+ matter; frames have `meta.title`. When the human says "the Checkout A/B board", the
147
+ manifest maps that title to its slug. Rename a slug only when asked, as one refactor.
138
148
  - **Descriptions.** Every object takes one optional `description`: one sentence, what it
139
149
  is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
140
150
  in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
@@ -81,7 +81,10 @@ planning. The human should see the request land on the canvas within the first m
81
81
  lit frame, never before one.
82
82
  3. Build. Independent frames can go in parallel - one subagent per frame, each marking
83
83
  its own; frames that depend on one another go in order.
84
- 4. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
84
+ 4. **Look before you say done**: `npx marver shot --scene <scene>` (or `<scene/frame ...>`,
85
+ `--all`) renders the frames headless in one go - one PNG path per line - and you READ
86
+ the PNGs. No shell? instructions/jam.md has the file-drop way (`{"scene":"..."}`).
87
+ 5. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
85
88
  self-expire (default 10 min; `--ttl <min>` up to 30) - re-run `start` on long jobs,
86
89
  and never lean on expiry instead of `done`.
87
90
 
@@ -132,8 +135,15 @@ Report where the request came from: chat requests get chat replies; only comment
132
135
  ## Orientation
133
136
  - design/manifest.json is the canvas with its purpose: the project (name, description),
134
137
  every folder and board in sidebar order, every scene and frame - each with its
135
- `description` when one was written. Read it before exploring; `marver dev` keeps it
136
- fresh. `npx marver boards` prints the boards part as a tree.
138
+ `description` when one was written, and its `title` when the human (or you) named it.
139
+ Read it before exploring; `marver dev` keeps it fresh. `npx marver boards` prints the
140
+ boards part as a tree.
141
+ - **Names vs titles.** A board's file name, a folder's `name`, a scene's directory are
142
+ IDENTITIES: slugs you address, that publish.json, URLs and comment threads hold, and
143
+ that never move on a rename. `title` is what humans see - free text (casing,
144
+ punctuation, emoji) on the board JSON, the `_folders.json` entry, the brief's front
145
+ matter; frames have `meta.title`. When the human says "the Checkout A/B board", the
146
+ manifest maps that title to its slug. Rename a slug only when asked, as one refactor.
137
147
  - **Descriptions.** Every object takes one optional `description`: one sentence, what it
138
148
  is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
139
149
  in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
@@ -10,10 +10,20 @@ viewport and lays it out:
10
10
 
11
11
  ```json
12
12
  { "version": 1, "name": "checkout-compare", "order": 1, "auto": false,
13
+ "title": "Checkout A/B",
13
14
  "description": "Cart step, direction A vs B side by side - B is the current favourite",
14
15
  "nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
15
16
  ```
16
17
 
18
+ - **The file name is the board's identity** - what you, `publish.json`, URLs and
19
+ comment threads address (`board: checkout-compare`). It is a slug
20
+ (`^[a-z0-9][a-z0-9-]*$`) and never moves on a rename.
21
+ - `title` - what humans SEE: free text, any casing, punctuation, emoji ("MVP", "UI",
22
+ "Checkout (v2) 🛒"). Optional: without one the sidebar Title-Cases the slug
23
+ (`checkout-compare` → "Checkout Compare"), which is fine for most boards. Write a
24
+ title when the slug would read wrong (`mvp` → "Mvp") or when the human names it.
25
+ The human's Rename in the sidebar edits the title only; the manifest carries both, so
26
+ "the Checkout A/B board" resolves to `checkout-compare`.
17
27
  - `description` - one sentence on what the board is for and where it stands. It is
18
28
  how a later session (or the human's next agent) knows this board without opening
19
29
  it; it lands in design/manifest.json. Write it at creation, keep it true.
@@ -28,8 +38,9 @@ viewport and lays it out:
28
38
  orienting board (an overview or the primary flow) - never a giant one. Boards
29
39
  without an `order` sort after the ranked ones, by name. Set `order` deliberately on
30
40
  every curated board; it is the first impression. The human can also drag-reorder boards
31
- in the sidebar (which rewrites `order`), rename one from its right-click menu, and file
32
- boards into folders (below) - so your ranking is a starting point they may adjust.
41
+ in the sidebar (which rewrites `order`), retitle one from its right-click menu (which
42
+ writes `title`), and file boards into folders (below) - so your ranking is a starting
43
+ point they may adjust.
33
44
  - `auto: false` boards show exactly their list. `all-scenes` is auto-managed (it holds
34
45
  EVERY frame, so it is the heavy one) and always sinks to the BOTTOM of the switcher -
35
46
  never the landing board, and never write its file.
@@ -64,12 +75,14 @@ Files are the truth, and two files carry it:
64
75
 
65
76
  ```json
66
77
  { "version": 1, "folders": [
67
- { "name": "research", "order": 1, "description": "The thinking behind the live boards - specs, flows, references" },
78
+ { "name": "research", "order": 1, "title": "R&D", "description": "The thinking behind the live boards - specs, flows, references" },
68
79
  { "name": "archive", "order": 3, "description": "Retired directions and scene versions, oldest first" } ] }
69
80
  ```
70
81
 
71
- A folder's `description` says what belongs in it - the next session files boards
72
- right without asking.
82
+ A folder's `name` is its slug - the identity its boards point at with `folder`; its
83
+ `title` (optional, free text) is what humans see, exactly as on a board; its
84
+ `description` says what belongs in it - the next session files boards right without
85
+ asking.
73
86
 
74
87
  It exists so an EMPTY folder can exist and so a folder has a rank at the root.
75
88
  A folder a board names but the registry lacks is still real (it sorts after the
@@ -91,8 +104,12 @@ The moves, each a file edit, so the files always agree:
91
104
  an `order` among the top-level items.
92
105
  - **Rank** folders and boards: `order` on the board (among its siblings) and on the
93
106
  registry entry (among the top-level items). Renumber the siblings you touch.
94
- - **Rename** a folder: rewrite `folder` on every member AND the registry entry - a
95
- registry rename alone leaves the members in the old (implied) folder.
107
+ - **Retitle** a folder (or a board): set `title` on the registry entry (on the board
108
+ file). **Rename a slug** - a folder's `name`, a board's file name - only when asked,
109
+ and as one refactor: a folder slug is on every member's `folder` field (rewrite them
110
+ all, AND the registry entry - a registry rename alone leaves the members in the old,
111
+ implied folder); a board file name is in `publish.json`, in its comment threads and in
112
+ every path anyone copied. A title does what a rename usually wanted.
96
113
  - **Delete** a folder: remove `folder` from every member, then its registry entry.
97
114
  Folders organise, never own: deleting one never deletes a board.
98
115
  - The **landing board** is the first board in sidebar order, folders included -
@@ -105,8 +122,9 @@ last. Propose the grouping in one sentence and do it; keep folder names short an
105
122
  plain.
106
123
 
107
124
  The human does all of this too - from the sidebar: New folder (right-click the Boards
108
- header, or its `+`), Rename, Delete folder, "Move to …" on a board, and DRAG: boards
109
- into and out of folders, folders among boards. Each drag rewrites `order` (and
125
+ header, or its `+`), Rename (the title - slugs never move from the sidebar), Delete
126
+ folder, "Move to …" on a board, and DRAG: boards into and out of folders, folders among
127
+ boards. Each drag rewrites `order` (and
110
128
  `folder`) on the boards it touches and the registry - the shell owns those fields
111
129
  while the canvas is open, exactly as it owns `order`; write membership and new
112
130
  folders freely, and never rewrite an arrangement the human just made. The shell