@marver-design/marver 0.15.0 → 0.16.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 +75 -0
  2. package/README.md +2 -1
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
  6. package/dist/cli.mjs +13 -4
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
  12. package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +4 -246
  17. package/src/client/shell/BoardList.tsx +408 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/icons.tsx +6 -0
  20. package/src/client/shell/store.ts +105 -47
  21. package/src/client/shell/styles.css +36 -4
  22. package/src/shared/board-tree.ts +285 -0
  23. package/templates/AGENTS-embedded.md +18 -5
  24. package/templates/AGENTS-studio.md +18 -5
  25. package/templates/instructions/boards.md +73 -2
  26. package/templates/instructions/craft.md +4 -0
  27. package/templates/instructions/discover.md +7 -2
  28. package/templates/instructions/iterate.md +5 -4
  29. package/templates/instructions/review.md +4 -0
  30. package/templates/instructions/shape.md +1 -1
  31. package/templates/instructions/welcome.md +4 -1
  32. package/templates/instructions/wireframe.md +3 -0
  33. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -12,6 +12,8 @@ import shData from 'virtual:sh-data'
12
12
  * is where `/` opens - never a synthesized aggregate of a filtered build. */
13
13
  const DATA: {
14
14
  manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
15
+ /** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */
16
+ tree?: TreeItem[]
15
17
  /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
16
18
  policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
17
19
  } | null = shData
@@ -123,19 +125,42 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
123
125
  export { cap, humanize } from './labels.ts'
124
126
  import { cap, humanize } from './labels.ts'
125
127
  import { canAutoReload } from './canvas/ready-watch.ts'
128
+ import { buildTree, flatten, toWire, type TreeItem } from '../../shared/board-tree.ts'
126
129
 
127
- /** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
128
- * name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
130
+ /** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
131
+ * the folder registry (null = there was no file). */
132
+ export interface TreeBase { boards: Record<string, string>; folders: string | null }
133
+ export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase }
134
+
135
+ /** The sidebar tree: root boards and folders in rank order, each folder's boards inside
136
+ * (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
137
+ * is pinned last by the callers. Throws on transport failure and on a malformed registry
138
+ * (the server's 422 message) - callers keep their last known tree. */
139
+ 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 } }
141
+ 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 }[]>,
143
+ fetch(`${ROUTE}/api/folders`).then(async (r) => {
144
+ const j = await r.json() as { folders?: { name: string; order?: number }[]; sha256?: string | null; error?: string }
145
+ if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
146
+ return j
147
+ }),
148
+ ])
149
+ return {
150
+ tree: buildTree(boards, reg.folders ?? []),
151
+ base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
152
+ }
153
+ }
154
+
155
+ /** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
156
+ * the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
129
157
  * Throws on transport failure - callers keep their last known list. */
130
158
  export async function fetchBoardNames(): Promise<string[]> {
131
159
  if (DATA) return DATA.names
132
- const list: { name: string; order?: number }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
133
- const curated = list
134
- .filter((b) => b.name !== 'all-scenes')
135
- .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
136
- .map((b) => b.name)
137
- return [...curated, 'all-scenes']
160
+ return [...flatten((await fetchBoardTree()).tree), 'all-scenes']
138
161
  }
162
+ /** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
163
+ export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
139
164
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
140
165
  export const boardLabel = (n: string) => humanize(n)
141
166
 
@@ -280,7 +305,7 @@ interface State {
280
305
  resizeSelected(name: string | null): void
281
306
  switchBoard(name: string): Promise<void>
282
307
  renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
283
- reorderBoards(order: string[]): Promise<boolean>
308
+ arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
284
309
  pulsePath(): void
285
310
  copyFrameImage(scale: 2 | 4): void
286
311
  setScale(s: number): void
@@ -317,8 +342,18 @@ export const useStore = create<State>((set, get) => {
317
342
  let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit
318
343
  let loadSeq = 0 // stale boot() responses never overwrite a newer board
319
344
  let switchSeq = 0 // last click wins when board switches race
320
- let renameLock = false // while a board file is being renamed, autosave must not fire against the OLD name (a mustExist PUT would 409-gone and clear dirty, losing the in-flight edit)
321
- const scheduleSave = () => { editRev++; if (renameLock) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
345
+ // While a board file is being renamed or rewritten by a structural write (rename, the
346
+ // sidebar's tree arrange), autosave must not fire against it (a mustExist PUT would
347
+ // 409-gone and clear dirty, losing the in-flight edit; a stale baseHash would 409 and
348
+ // reload). A COUNT, not a flag: two holders never release each other. Structural writes
349
+ // also run one at a time through `structChain`, so a rename can never interleave a
350
+ // tree write on the same file.
351
+ let saveHolds = 0
352
+ const holdSaves = () => { saveHolds++; clearTimeout(saveTimer) }
353
+ const releaseSaves = () => { if (--saveHolds === 0 && get().dirty) scheduleSave() }
354
+ let structChain: Promise<unknown> = Promise.resolve()
355
+ const structural = <T,>(fn: () => Promise<T>): Promise<T> => { const p = structChain.then(fn, fn); structChain = p.catch(() => {}); return p }
356
+ const scheduleSave = () => { editRev++; if (saveHolds > 0) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
322
357
 
323
358
  // One cancelable, BOARD-SCOPED reflow after content measurements settle.
324
359
  // The captured board name is the generation guard - a debounce surviving a board
@@ -616,43 +651,66 @@ export const useStore = create<State>((set, get) => {
616
651
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
617
652
  },
618
653
 
619
- async renameBoard(from, to) {
620
- const active = from === get().board
621
- // Renaming the ACTIVE board renames its file out from under the autosave. Flush any
622
- // pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
623
- if (active) {
624
- let ok = true
625
- for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
626
- if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
627
- // hold autosave across the round-trip: an edit landing mid-rename must NOT save to the
628
- // old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
629
- renameLock = true
630
- clearTimeout(saveTimer)
631
- }
632
- // Release the lock and resume autosave. Re-read the LIVE board, not the captured `active`:
633
- // a board switch may have completed while we awaited, so only the board that is STILL
634
- // `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
635
- const release = () => { if (active) { renameLock = false; if (get().dirty) scheduleSave() } }
636
- let res: Response
637
- try { res = await postOwner('boards/rename', { from, to }) }
638
- catch { release(); return { ok: false, error: 'could not reach the dev server' } }
639
- if (!res.ok) {
640
- release()
641
- const e = await res.json().catch(() => ({} as { error?: string }))
642
- return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
643
- }
644
- // Content is byte-identical after a file move, so boardHash still matches for the next
645
- // autosave; only the name (and the URL, via the board-change subscription) changes. Set
646
- // the new name BEFORE releasing the lock so a resumed save targets `to`.
647
- if (active && get().board === from) set({ board: to })
648
- release()
649
- return { ok: true }
654
+ renameBoard(from, to) {
655
+ return structural(async () => {
656
+ 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.
659
+ if (active) {
660
+ let ok = true
661
+ for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
662
+ 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)
665
+ holdSaves()
666
+ }
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
+ try {
671
+ let res: Response
672
+ try { res = await postOwner('boards/rename', { from, to }) }
673
+ 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 })
682
+ return { ok: true }
683
+ } finally { if (active) releaseSaves() } // whatever happened, autosave resumes
684
+ })
650
685
  },
651
686
 
652
- async reorderBoards(order) {
653
- let res: Response
654
- try { res = await postOwner('boards/reorder', { order }) } catch { return false }
655
- return res.ok
687
+ // The whole sidebar tree in one write: order, membership, the folders themselves. The
688
+ // write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
689
+ // rename: flush the pending autosave first, hold autosave across the round-trip, then
690
+ // advance boardHash to the hash the server answered - the autosave's CAS token stays
691
+ // true and the watcher's echo is filtered as our own. `base` carries the hashes the
692
+ // sidebar last saw; a 409 (`stale`) means someone else wrote first - refetch and replay.
693
+ arrangeBoards(tree, base) {
694
+ return structural(async () => {
695
+ let ok = true
696
+ for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
697
+ if (get().dirty) return { ok: false as const, stale: false, error: 'unsaved changes - try again' }
698
+ holdSaves()
699
+ try {
700
+ // the active board's hash is freshest in the store (an autosave may have landed since
701
+ // the sidebar looked); every other board's is the sidebar's
702
+ const active = get().board
703
+ const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
704
+ let res: Response
705
+ try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
706
+ catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
707
+ const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
708
+ if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
709
+ const sha = body?.sha256?.boards?.[get().board]
710
+ if (sha) set({ boardHash: sha })
711
+ return { ok: true as const }
712
+ } finally { releaseSaves() } // whatever happened, autosave resumes
713
+ })
656
714
  },
657
715
 
658
716
  applyManifest(m) {
@@ -1141,7 +1199,7 @@ export const useStore = create<State>((set, get) => {
1141
1199
  save() {
1142
1200
  // a rename is moving this board's file - defer every save so nothing writes to the OLD
1143
1201
  // name mid-move (renameBoard reschedules once the move commits under the new name)
1144
- if (renameLock) return Promise.resolve(false)
1202
+ if (saveHolds > 0) return Promise.resolve(false)
1145
1203
  // a resize gesture in flight = torn state (new sizes, pre-recipe positions):
1146
1204
  // even a PREVIOUSLY scheduled timer must defer to the gesture-end save
1147
1205
  if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
@@ -505,9 +505,38 @@ body.sh-commenting .sh-lean { opacity: 0 }
505
505
  .sh-panel .it.board.drop-before::after { top: -1px }
506
506
  .sh-panel .it.board.drop-after::after { bottom: -1px }
507
507
  /* inline rename: the input wears the row's own type so the swap is seamless */
508
- .sh-panel .it.board.editing { cursor: default }
509
- .sh-panel .it.board.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
508
+ .sh-panel .it.board.editing, .sh-panel .it.folder.editing { cursor: default }
509
+ .sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
510
510
  color: var(--glass-ink); font: inherit }
511
+ .sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
512
+ /* folders: one level, same row grammar as boards (idle icon tertiary, count tertiary). A folder
513
+ holding the current board wears the ancestor wash so the active board's home survives a
514
+ collapse; its boards indent one step. Folder rows share the board seam rules; dropping INTO a
515
+ folder lights the whole row (wash + brand-blue ring) instead of a seam. */
516
+ .sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
517
+ .sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
518
+ .sh-panel .it.folder.dragging { opacity: .4 }
519
+ .sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
520
+ .sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
521
+ content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
522
+ background: #0088ff; pointer-events: none }
523
+ .sh-panel .it.folder.drop-before::after { top: -1px }
524
+ .sh-panel .it.folder.drop-after::after { bottom: -1px }
525
+ .sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
526
+ .sh-panel .it.folder.drop-into svg { color: #0088ff }
527
+ .sh-panel .it.board.in-folder { padding-left: 28px }
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 */
530
+ .sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
531
+ /* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
532
+ .sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
533
+ .sh-panel .sh-boards .hd span { flex: 1 }
534
+ /* a bare glyph, no button chrome (padding would shrink it): tertiary, pointer, full ink on hover */
535
+ .sh-panel .sh-hd-add { flex: none; color: var(--glass-ink-3); cursor: pointer; outline: none; border-radius: 4px;
536
+ opacity: 0; transition: opacity .15s ease, color .15s ease }
537
+ .sh-panel .sh-boards .hd:hover .sh-hd-add, .sh-panel .sh-hd-add:focus-visible { opacity: 1 }
538
+ .sh-panel .sh-hd-add:hover { color: var(--glass-ink) }
539
+ .sh-panel .sh-hd-add:focus-visible { box-shadow: 0 0 0 2px var(--accent) }
511
540
  /* frame rows: one indent in (aligned to the label line), quieter ink */
512
541
  .sh-panel .sub { display: flex; align-items: center; height: 28px; padding: 0 8px 0 31px; margin-bottom: 1px;
513
542
  color: var(--glass-ink-2); font-size: 13px; font-weight: 400; border-radius: 8px; cursor: pointer }
@@ -562,8 +591,11 @@ body.sh-commenting .sh-lean { opacity: 0 }
562
591
  cursor: pointer; text-align: left }
563
592
  .sh-menu button:hover { background: var(--glass-hover) }
564
593
  .sh-menu button span { flex: 1; text-transform: capitalize }
565
- /* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly) */
566
- .sh-ctxmenu button span { text-transform: none }
594
+ /* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly);
595
+ it scrolls rather than overflowing the window when the folder list grows long */
596
+ .sh-ctxmenu { max-height: calc(100vh - 16px); max-width: 240px; overflow-y: auto; scrollbar-width: thin }
597
+ .sh-ctxmenu button { flex: none; max-width: 100% }
598
+ .sh-ctxmenu button span { text-transform: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0 }
567
599
  .sh-menu .chk { color: var(--glass-accent) }
568
600
  .sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
569
601
  .sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Board folders - the pure tree shared by the sidebar, the dev API, the build and the
3
+ * tests. Files are the truth: a board says which folder it sits in (`folder` on the
4
+ * board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
5
+ * says which folders exist and where they rank at the root. One level only: folders
6
+ * hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
7
+ */
8
+
9
+ /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
10
+ export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
11
+ export const NAME_MAX = 64
12
+ export const isBoardName = (n: unknown): n is string => typeof n === 'string' && n.length >= 1 && n.length <= NAME_MAX && BOARD_NAME.test(n)
13
+
14
+ /** The folder registry beside the boards - underscore = infrastructure, never a board. */
15
+ export const FOLDERS_FILE = '_folders.json'
16
+ /** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
17
+ * off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
18
+ export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
19
+
20
+ export type Folder = { kind: 'folder'; name: string; boards: string[]; description?: string }
21
+ export type TreeItem = { kind: 'board'; name: string } | Folder
22
+
23
+ export interface BoardRow { name: string; order?: number; folder?: string }
24
+ export interface FolderRow { name: string; order?: number; description?: string }
25
+
26
+ /** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
27
+ export const DESCRIPTION_MAX = 300
28
+ export const readDescription = (v: unknown): string | undefined => {
29
+ if (typeof v !== 'string') return undefined
30
+ const s = v.trim().replace(/\s+/g, ' ').slice(0, DESCRIPTION_MAX)
31
+ return s || undefined
32
+ }
33
+
34
+ const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
35
+
36
+ /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
37
+ * malformed registry is an ERROR the human must fix (silently reading it as empty would
38
+ * let the next drag overwrite their folders), while a missing file is simply no folders. */
39
+ export function parseFolders(raw: unknown): FolderRow[] | string {
40
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
41
+ const { version, folders } = raw as { version?: unknown; folders?: unknown }
42
+ if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
43
+ if (!Array.isArray(folders)) return 'expected a "folders" array'
44
+ const out: FolderRow[] = []
45
+ const seen = new Set<string>()
46
+ for (const f of folders) {
47
+ const name = (f as { name?: unknown })?.name
48
+ if (!isBoardName(name)) return 'a folder needs a name - lowercase letters, numbers and dashes'
49
+ if (seen.has(name)) return `folder "${name}" is listed twice`
50
+ seen.add(name)
51
+ const o = (f as { order?: unknown }).order
52
+ const d = readDescription((f as { description?: unknown }).description)
53
+ out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(d ? { description: d } : {}) })
54
+ }
55
+ return out
56
+ }
57
+
58
+ /** Sidebar order from the files. Root: boards with no folder + every folder (registered
59
+ * 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. */
61
+ export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
62
+ 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) }
65
+ const members = new Map<string, BoardRow[]>()
66
+ const rootBoards: BoardRow[] = []
67
+ for (const b of boards) {
68
+ if (!isBoardName(b.name) || b.name === 'all-scenes') continue
69
+ const folder = isBoardName(b.folder) ? b.folder : undefined
70
+ if (!folder) { rootBoards.push(b); continue }
71
+ if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
72
+ const list = members.get(folder) ?? []
73
+ list.push(b)
74
+ members.set(folder, list)
75
+ }
76
+ const byRank = (a: BoardRow, b: BoardRow) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name)
77
+ type Root = { item: TreeItem; order: number | undefined }
78
+ const root: Root[] = [
79
+ ...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
80
+ ...[...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,
82
+ order,
83
+ })),
84
+ ]
85
+ root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === 'board' ? -1 : 1) || a.item.name.localeCompare(b.item.name))
86
+ return root.map((r) => r.item)
87
+ }
88
+
89
+ /** Every board in reading order - the order the switchers and the landing pick use. */
90
+ export function flatten(tree: TreeItem[]): string[] {
91
+ const out: string[] = []
92
+ for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
93
+ return out
94
+ }
95
+
96
+ /** 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 }
99
+ 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 } : {}) }))
101
+ 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 } : {}) }))
103
+
104
+ /** Validate a wire tree off the network. Returns the error, or null when it is sound:
105
+ * every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
106
+ * nesting (a folder's boards are strings), bounded. */
107
+ export const TREE_MAX_BOARDS = 200
108
+ export const TREE_MAX_FOLDERS = 50
109
+ export function validateWire(wire: unknown): string | null {
110
+ if (!Array.isArray(wire)) return 'invalid tree'
111
+ const boards = new Set<string>(), folders = new Set<string>()
112
+ const board = (n: unknown): string | null => {
113
+ if (!isBoardName(n) || n === 'all-scenes') return 'invalid board name in tree'
114
+ if (boards.has(n)) return `board "${n}" appears twice`
115
+ boards.add(n)
116
+ return null
117
+ }
118
+ for (const w of wire) {
119
+ if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
120
+ 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 }
122
+ if (!isBoardName(folder)) return 'invalid folder name in tree'
123
+ if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
124
+ if (folders.has(folder)) return `folder "${folder}" appears twice`
125
+ folders.add(folder)
126
+ if (!Array.isArray(kids)) return 'invalid folder in tree'
127
+ for (const k of kids) { const e = board(k); if (e) return e }
128
+ }
129
+ if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
130
+ return null
131
+ }
132
+
133
+ /** What the human types becomes a slug: "Old stuff" → "old-stuff". Empty when nothing
134
+ * survives - the caller keeps the input open and says so. Always on-grammar or empty. */
135
+ export function slugify(raw: string): string {
136
+ const s = raw.trim().toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '').slice(0, NAME_MAX).replace(/-+$/g, '')
137
+ return isBoardName(s) ? s : ''
138
+ }
139
+
140
+ // ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
141
+
142
+ export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, boards: [...it.boards] })) // a folder's description rides along
143
+ export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => t.findIndex((it) => it.kind === kind && it.name === name)
144
+ export const folderIn = (t: TreeItem[], name: string): Folder | undefined => t.find((it): it is Folder => it.kind === 'folder' && it.name === name)
145
+ export const folderOf = (t: TreeItem[], board: string): string | null => t.find((it): it is Folder => it.kind === 'folder' && it.boards.includes(board))?.name ?? null
146
+ export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
147
+ export const foldersIn = (t: TreeItem[]): string[] => t.filter((it) => it.kind === 'folder').map((it) => it.name)
148
+
149
+ /** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
150
+ export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
151
+ const ri = rootIndex(t, 'board', board)
152
+ if (ri >= 0) { t.splice(ri, 1); return { list: null, index: ri } }
153
+ for (const it of t) {
154
+ if (it.kind !== 'folder') continue
155
+ const i = it.boards.indexOf(board)
156
+ if (i >= 0) { it.boards.splice(i, 1); return { list: it.name, index: i } }
157
+ }
158
+ return null
159
+ }
160
+
161
+ /** What is being dragged, and where it may land: a slot in a list (root = null) or the
162
+ * inside of a folder (appended at its end). */
163
+ export type Drag = { kind: TreeItem['kind']; name: string }
164
+ export type Drop = { list: string | null; index: number } | { into: string }
165
+
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
+ }
174
+
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
184
+ }
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 }
192
+ }
193
+ const i = rootIndex(t, 'board', hit.name)
194
+ return i < 0 ? null : { list: null, index: hit.below ? i + 1 : i }
195
+ }
196
+
197
+ /** A target that would leave the item where it is: nothing to show, nothing to drop. Into
198
+ * the folder a board already sits in means "to its end" - a no-op only when it is last. */
199
+ export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
200
+ if ('into' in target) {
201
+ if (d.kind !== 'board') return true
202
+ const f = folderIn(t, target.into)
203
+ return !!f && f.boards[f.boards.length - 1] === d.name
204
+ }
205
+ let from = -1
206
+ if (d.kind === 'folder') from = target.list === null ? rootIndex(t, 'folder', d.name) : -1
207
+ else if (target.list === null) from = rootIndex(t, 'board', d.name)
208
+ else from = folderIn(t, target.list)?.boards.indexOf(d.name) ?? -1
209
+ return from >= 0 && (target.index === from || target.index === from + 1)
210
+ }
211
+
212
+ /** The tree after a drop, or null when the drop is impossible on this tree. */
213
+ export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
214
+ const next = cloneTree(tree)
215
+ if (d.kind === 'folder') {
216
+ if ('into' in target || target.list !== null) return null
217
+ const from = rootIndex(next, 'folder', d.name)
218
+ if (from < 0) return null
219
+ const [it] = next.splice(from, 1)
220
+ next.splice(target.index > from ? target.index - 1 : target.index, 0, it!) // removing `from` shifts later slots left
221
+ return next
222
+ }
223
+ const src = takeBoard(next, d.name)
224
+ if (!src) return null
225
+ if ('into' in target) {
226
+ const f = folderIn(next, target.into)
227
+ if (!f) return null
228
+ f.boards.push(d.name)
229
+ return next
230
+ }
231
+ const to = src.list === target.list && target.index > src.index ? target.index - 1 : target.index
232
+ if (target.list === null) { next.splice(Math.min(to, next.length), 0, { kind: 'board', name: d.name }); return next }
233
+ const f = folderIn(next, target.list)
234
+ if (!f) return null
235
+ f.boards.splice(Math.min(to, f.boards.length), 0, d.name)
236
+ return next
237
+ }
238
+
239
+ /** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
240
+ export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
241
+ const next = cloneTree(tree)
242
+ if (!takeBoard(next, board)) return null
243
+ if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
244
+ const f = folderIn(next, folder)
245
+ if (!f) return null
246
+ f.boards.push(board)
247
+ return next
248
+ }
249
+
250
+ /** 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 {
252
+ if (foldersIn(tree).includes(name)) return null
253
+ const next = cloneTree(tree)
254
+ const boards: string[] = []
255
+ if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
256
+ next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards })
257
+ return next
258
+ }
259
+
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
263
+ const next = cloneTree(tree)
264
+ const f = folderIn(next, from)
265
+ if (!f) return null
266
+ f.name = to
267
+ return next
268
+ }
269
+
270
+ /** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
271
+ export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
272
+ const next = cloneTree(tree)
273
+ const i = rootIndex(next, 'folder', name)
274
+ if (i < 0) return null
275
+ const f = next[i] as Folder
276
+ next.splice(i, 1, ...f.boards.map((b): TreeItem => ({ kind: 'board', name: b })))
277
+ return next
278
+ }
279
+
280
+ /** Where "Move to new folder" puts the folder: the board's own root slot, or right after the
281
+ * folder it sits in. */
282
+ export const newFolderSlot = (tree: TreeItem[], board: string): number => {
283
+ const parent = folderOf(tree, board)
284
+ return parent ? rootIndex(tree, 'folder', parent) + 1 : Math.max(0, rootIndex(tree, 'board', board))
285
+ }
@@ -91,7 +91,7 @@ Report where the request came from: chat requests get chat replies; only comment
91
91
  ## Frames
92
92
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
93
93
  - It default-exports a React component. No imports from the tool are needed. Optional:
94
- export const meta = { title: "...", viewport: "mobile" } // literal values only
94
+ export const meta = { title: "...", viewport: "mobile", description: "..." } // literal values only
95
95
  // viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
96
96
  // tv available commented-out). Pick the one the screen is designed for - the human can
97
97
  // flip the whole board to any device (Devices menu; digit keys - 0 restores each
@@ -131,8 +131,17 @@ Report where the request came from: chat requests get chat replies; only comment
131
131
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
132
132
 
133
133
  ## Orientation
134
- - design/manifest.json lists every frame (id, file, scene, title) - read it before
135
- exploring. `init` writes the first one; `marver dev` keeps it fresh.
134
+ - design/manifest.json is the canvas with its purpose: the project (name, description),
135
+ 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.
138
+ - **Descriptions.** Every object takes one optional `description`: one sentence, what it
139
+ is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
140
+ in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
141
+ entry. Scene: the FIRST line of its `_brief.md`. Frame: `meta.description`. Write it
142
+ when you create the thing; keep it true when the state changes (retired, winning
143
+ direction, superseded); before a session ends, re-read the manifest and fix any
144
+ description your session made false. That is how the next session orients in one read.
136
145
  - Component galleries: create design/components/<name>/variants.tsx rendering each variant
137
146
  and each state (default / hover-styled / focus / disabled / loading) of one ui component.
138
147
 
@@ -159,8 +168,12 @@ edges; a variant-group name is one indivisible atom). **The default composition
159
168
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
160
169
  band only when you can say why the eye should move down, and then with generous
161
170
  vertical space. Without a recipe the shell stacks every scene as its own row - so
162
- every curated board carries one. BEFORE creating a board or publishing anything,
163
- read instructions/boards.md (the layout grammar, file format, publishing rules).
171
+ every curated board carries one. Boards can sit in **folders** (one level): put
172
+ `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
173
+ and ranks them - the human creates, renames, and drags folders in the sidebar too, so
174
+ run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
175
+ creating a board, organising boards, or publishing anything, read instructions/boards.md
176
+ (the layout grammar, file format, folders and their moves, publishing rules).
164
177
 
165
178
  A round of feedback on a scene the human has already reviewed starts with a
166
179
  **version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
@@ -91,7 +91,7 @@ Report where the request came from: chat requests get chat replies; only comment
91
91
  ## Frames
92
92
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
93
93
  - It default-exports a React component. No imports from the tool are needed. Optional:
94
- export const meta = { title: "...", viewport: "mobile" } // literal values only
94
+ export const meta = { title: "...", viewport: "mobile", description: "..." } // literal values only
95
95
  // viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
96
96
  // tv available commented-out). Pick the one the screen is designed for - the human can
97
97
  // flip the whole board to any device (Devices menu; digit keys - 0 restores each
@@ -130,8 +130,17 @@ Report where the request came from: chat requests get chat replies; only comment
130
130
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
131
131
 
132
132
  ## Orientation
133
- - design/manifest.json lists every frame (id, file, scene, title) - read it before
134
- exploring. `init` writes the first one; `marver dev` keeps it fresh.
133
+ - design/manifest.json is the canvas with its purpose: the project (name, description),
134
+ 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.
137
+ - **Descriptions.** Every object takes one optional `description`: one sentence, what it
138
+ is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
139
+ in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
140
+ entry. Scene: the FIRST line of its `_brief.md`. Frame: `meta.description`. Write it
141
+ when you create the thing; keep it true when the state changes (retired, winning
142
+ direction, superseded); before a session ends, re-read the manifest and fix any
143
+ description your session made false. That is how the next session orients in one read.
135
144
  - Component galleries: create design/components/<name>/variants.tsx rendering each variant
136
145
  and each state (default / hover-styled / focus / disabled / loading) of one ui component.
137
146
 
@@ -159,8 +168,12 @@ edges; a variant-group name is one indivisible atom). **The default composition
159
168
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
160
169
  band only when you can say why the eye should move down, and then with generous
161
170
  vertical space. Without a recipe the shell stacks every scene as its own row - so
162
- every curated board carries one. BEFORE creating a board or publishing anything,
163
- read instructions/boards.md (the layout grammar, file format, publishing rules).
171
+ every curated board carries one. Boards can sit in **folders** (one level): put
172
+ `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
173
+ and ranks them - the human creates, renames, and drags folders in the sidebar too, so
174
+ run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
175
+ creating a board, organising boards, or publishing anything, read instructions/boards.md
176
+ (the layout grammar, file format, folders and their moves, publishing rules).
164
177
 
165
178
  A round of feedback on a scene the human has already reviewed starts with a
166
179
  **version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE