@marver-design/marver 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +2 -1
  3. package/dist/boards-BmxcT3Lc.mjs +290 -0
  4. package/dist/boards-PuVzw5Wp.mjs +62 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
  6. package/dist/cli.mjs +33 -13
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
  12. package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +45 -259
  17. package/src/client/shell/BoardList.tsx +438 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/LockedApp.tsx +2 -1
  20. package/src/client/shell/Play.tsx +1 -0
  21. package/src/client/shell/icons.tsx +6 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +156 -51
  24. package/src/client/shell/styles.css +40 -4
  25. package/src/shared/board-tree.ts +343 -0
  26. package/templates/AGENTS-embedded.md +25 -5
  27. package/templates/AGENTS-studio.md +25 -5
  28. package/templates/instructions/boards.md +91 -2
  29. package/templates/instructions/craft.md +4 -0
  30. package/templates/instructions/discover.md +20 -3
  31. package/templates/instructions/iterate.md +5 -4
  32. package/templates/instructions/review.md +4 -0
  33. package/templates/instructions/shape.md +1 -1
  34. package/templates/instructions/welcome.md +4 -1
  35. package/templates/instructions/wireframe.md +3 -0
  36. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -0,0 +1,343 @@
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[]; title?: string; description?: string }
21
+ export type TreeItem = { kind: 'board'; name: string } | Folder
22
+
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
+
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
+ /** 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
+
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 } : {}) })
52
+
53
+ /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
54
+ * malformed registry is an ERROR the human must fix (silently reading it as empty would
55
+ * let the next drag overwrite their folders), while a missing file is simply no folders. */
56
+ export function parseFolders(raw: unknown): FolderRow[] | string {
57
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
58
+ const { version, folders } = raw as { version?: unknown; folders?: unknown }
59
+ if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
60
+ if (!Array.isArray(folders)) return 'expected a "folders" array'
61
+ const out: FolderRow[] = []
62
+ const seen = new Set<string>()
63
+ for (const f of folders) {
64
+ const name = (f as { name?: unknown })?.name
65
+ if (!isBoardName(name)) return 'a folder needs a name - lowercase letters, numbers and dashes'
66
+ if (seen.has(name)) return `folder "${name}" is listed twice`
67
+ seen.add(name)
68
+ const o = (f as { order?: unknown }).order
69
+ const t = readTitle((f as { title?: unknown }).title)
70
+ const d = readDescription((f as { description?: unknown }).description)
71
+ out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(t ? { title: t } : {}), ...(d ? { description: d } : {}) })
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** Sidebar order from the files. Root: boards with no folder + every folder (registered
77
+ * or implied by a board), ranked by `order` then kind (board before folder) then name.
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. */
81
+ export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
82
+ const folderOrder = new Map<string, number | undefined>()
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)) }
85
+ const members = new Map<string, BoardRow[]>()
86
+ const rootBoards: BoardRow[] = []
87
+ for (const b of boards) {
88
+ if (!isBoardName(b.name) || b.name === 'all-scenes') continue
89
+ const folder = isBoardName(b.folder) ? b.folder : undefined
90
+ if (!folder) { rootBoards.push(b); continue }
91
+ if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
92
+ const list = members.get(folder) ?? []
93
+ list.push(b)
94
+ members.set(folder, list)
95
+ }
96
+ const byRank = (a: BoardRow, b: BoardRow) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name)
97
+ type Root = { item: TreeItem; order: number | undefined }
98
+ const root: Root[] = [
99
+ ...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
100
+ ...[...folderOrder].map(([name, order]) => ({
101
+ item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderMeta.get(name) ?? {}) } as TreeItem,
102
+ order,
103
+ })),
104
+ ]
105
+ 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))
106
+ return root.map((r) => r.item)
107
+ }
108
+
109
+ /** Every board in reading order - the order the switchers and the landing pick use. */
110
+ export function flatten(tree: TreeItem[]): string[] {
111
+ const out: string[] = []
112
+ for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
113
+ return out
114
+ }
115
+
116
+ /** The wire shape of a tree write (`POST boards/reorder`): plain strings for root boards,
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 }
121
+ export const toWire = (tree: TreeItem[]): WireItem[] =>
122
+ tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...folderExtras(it) }))
123
+ export const fromWire = (wire: WireItem[]): TreeItem[] =>
124
+ wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...folderExtras(w) }))
125
+
126
+ /** Validate a wire tree off the network. Returns the error, or null when it is sound:
127
+ * every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
128
+ * nesting (a folder's boards are strings), bounded. */
129
+ export const TREE_MAX_BOARDS = 200
130
+ export const TREE_MAX_FOLDERS = 50
131
+ export function validateWire(wire: unknown): string | null {
132
+ if (!Array.isArray(wire)) return 'invalid tree'
133
+ const boards = new Set<string>(), folders = new Set<string>()
134
+ const board = (n: unknown): string | null => {
135
+ if (!isBoardName(n) || n === 'all-scenes') return 'invalid board name in tree'
136
+ if (boards.has(n)) return `board "${n}" appears twice`
137
+ boards.add(n)
138
+ return null
139
+ }
140
+ for (const w of wire) {
141
+ if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
142
+ if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
143
+ const { folder, boards: kids, title, description } = w as { folder?: unknown; boards?: unknown; title?: unknown; description?: unknown }
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'
146
+ if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
147
+ if (folders.has(folder)) return `folder "${folder}" appears twice`
148
+ folders.add(folder)
149
+ if (!Array.isArray(kids)) return 'invalid folder in tree'
150
+ for (const k of kids) { const e = board(k); if (e) return e }
151
+ }
152
+ if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
153
+ return null
154
+ }
155
+
156
+ /** What the human types becomes a slug: "Old stuff" → "old-stuff". Empty when nothing
157
+ * survives - the caller keeps the input open and says so. Always on-grammar or empty. */
158
+ export function slugify(raw: string): string {
159
+ const s = raw.trim().toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '').slice(0, NAME_MAX).replace(/-+$/g, '')
160
+ return isBoardName(s) ? s : ''
161
+ }
162
+
163
+ // ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
164
+
165
+ export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, boards: [...it.boards] })) // a folder's description rides along
166
+ export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => t.findIndex((it) => it.kind === kind && it.name === name)
167
+ export const folderIn = (t: TreeItem[], name: string): Folder | undefined => t.find((it): it is Folder => it.kind === 'folder' && it.name === name)
168
+ export const folderOf = (t: TreeItem[], board: string): string | null => t.find((it): it is Folder => it.kind === 'folder' && it.boards.includes(board))?.name ?? null
169
+ export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
170
+ export const foldersIn = (t: TreeItem[]): string[] => t.filter((it) => it.kind === 'folder').map((it) => it.name)
171
+
172
+ /** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
173
+ export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
174
+ const ri = rootIndex(t, 'board', board)
175
+ if (ri >= 0) { t.splice(ri, 1); return { list: null, index: ri } }
176
+ for (const it of t) {
177
+ if (it.kind !== 'folder') continue
178
+ const i = it.boards.indexOf(board)
179
+ if (i >= 0) { it.boards.splice(i, 1); return { list: it.name, index: i } }
180
+ }
181
+ return null
182
+ }
183
+
184
+ /** What is being dragged, and where it may land: a slot in a list (root = null) or the
185
+ * inside of a folder (appended at its end). */
186
+ export type Drag = { kind: TreeItem['kind']; name: string }
187
+ export type Drop = { list: string | null; index: number } | { into: string }
188
+
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
195
+
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 }
222
+ }
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 })
236
+ }
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 })
242
+ }
243
+
244
+ /** A target that would leave the item where it is: nothing to show, nothing to drop. Into
245
+ * the folder a board already sits in means "to its end" - a no-op only when it is last. */
246
+ export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
247
+ if ('into' in target) {
248
+ if (d.kind !== 'board') return true
249
+ const f = folderIn(t, target.into)
250
+ return !!f && f.boards[f.boards.length - 1] === d.name
251
+ }
252
+ let from = -1
253
+ if (d.kind === 'folder') from = target.list === null ? rootIndex(t, 'folder', d.name) : -1
254
+ else if (target.list === null) from = rootIndex(t, 'board', d.name)
255
+ else from = folderIn(t, target.list)?.boards.indexOf(d.name) ?? -1
256
+ return from >= 0 && (target.index === from || target.index === from + 1)
257
+ }
258
+
259
+ /** The tree after a drop, or null when the drop is impossible on this tree. */
260
+ export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
261
+ const next = cloneTree(tree)
262
+ if (d.kind === 'folder') {
263
+ if ('into' in target || target.list !== null) return null
264
+ const from = rootIndex(next, 'folder', d.name)
265
+ if (from < 0) return null
266
+ const [it] = next.splice(from, 1)
267
+ next.splice(target.index > from ? target.index - 1 : target.index, 0, it!) // removing `from` shifts later slots left
268
+ return next
269
+ }
270
+ const src = takeBoard(next, d.name)
271
+ if (!src) return null
272
+ if ('into' in target) {
273
+ const f = folderIn(next, target.into)
274
+ if (!f) return null
275
+ f.boards.push(d.name)
276
+ return next
277
+ }
278
+ const to = src.list === target.list && target.index > src.index ? target.index - 1 : target.index
279
+ if (target.list === null) { next.splice(Math.min(to, next.length), 0, { kind: 'board', name: d.name }); return next }
280
+ const f = folderIn(next, target.list)
281
+ if (!f) return null
282
+ f.boards.splice(Math.min(to, f.boards.length), 0, d.name)
283
+ return next
284
+ }
285
+
286
+ /** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
287
+ export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
288
+ const next = cloneTree(tree)
289
+ if (!takeBoard(next, board)) return null
290
+ if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
291
+ const f = folderIn(next, folder)
292
+ if (!f) return null
293
+ f.boards.push(board)
294
+ return next
295
+ }
296
+
297
+ /** A new folder at root `index`, holding `board` (pulled from wherever it sat) when given. */
298
+ export function createFolder(tree: TreeItem[], name: string, index: number, board?: string, title?: string): TreeItem[] | null {
299
+ if (foldersIn(tree).includes(name)) return null
300
+ const next = cloneTree(tree)
301
+ const boards: string[] = []
302
+ if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
303
+ next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards, ...(title ? { title } : {}) })
304
+ return next
305
+ }
306
+
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 {
310
+ const next = cloneTree(tree)
311
+ const f = folderIn(next, name)
312
+ if (!f) return null
313
+ if (title) f.title = title; else delete f.title
314
+ return next
315
+ }
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
+
328
+ /** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
329
+ export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
330
+ const next = cloneTree(tree)
331
+ const i = rootIndex(next, 'folder', name)
332
+ if (i < 0) return null
333
+ const f = next[i] as Folder
334
+ next.splice(i, 1, ...f.boards.map((b): TreeItem => ({ kind: 'board', name: b })))
335
+ return next
336
+ }
337
+
338
+ /** Where "Move to new folder" puts the folder: the board's own root slot, or right after the
339
+ * folder it sits in. */
340
+ export const newFolderSlot = (tree: TreeItem[], board: string): number => {
341
+ const parent = folderOf(tree, board)
342
+ return parent ? rootIndex(tree, 'folder', parent) + 1 : Math.max(0, rootIndex(tree, 'board', board))
343
+ }
@@ -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,24 @@ 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, and its `title` when the human (or you) named it.
137
+ Read it before exploring; `marver dev` keeps it fresh. `npx marver boards` prints the
138
+ boards part as a tree.
139
+ - **Names vs titles.** A board's file name, a folder's `name`, a scene's directory are
140
+ IDENTITIES: slugs you address, that publish.json, URLs and comment threads hold, and
141
+ that never move on a rename. `title` is what humans see - free text (casing,
142
+ punctuation, emoji) on the board JSON, the `_folders.json` entry, the brief's front
143
+ matter; frames have `meta.title`. When the human says "the Checkout A/B board", the
144
+ manifest maps that title to its slug. Rename a slug only when asked, as one refactor.
145
+ - **Descriptions.** Every object takes one optional `description`: one sentence, what it
146
+ is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
147
+ in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
148
+ entry. Scene: the FIRST line of its `_brief.md`. Frame: `meta.description`. Write it
149
+ when you create the thing; keep it true when the state changes (retired, winning
150
+ direction, superseded); before a session ends, re-read the manifest and fix any
151
+ description your session made false. That is how the next session orients in one read.
136
152
  - Component galleries: create design/components/<name>/variants.tsx rendering each variant
137
153
  and each state (default / hover-styled / focus / disabled / loading) of one ui component.
138
154
 
@@ -159,8 +175,12 @@ edges; a variant-group name is one indivisible atom). **The default composition
159
175
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
160
176
  band only when you can say why the eye should move down, and then with generous
161
177
  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).
178
+ every curated board carries one. Boards can sit in **folders** (one level): put
179
+ `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
180
+ and ranks them - the human creates, renames, and drags folders in the sidebar too, so
181
+ run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
182
+ creating a board, organising boards, or publishing anything, read instructions/boards.md
183
+ (the layout grammar, file format, folders and their moves, publishing rules).
164
184
 
165
185
  A round of feedback on a scene the human has already reviewed starts with a
166
186
  **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,24 @@ 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, and its `title` when the human (or you) named it.
136
+ Read it before exploring; `marver dev` keeps it fresh. `npx marver boards` prints the
137
+ boards part as a tree.
138
+ - **Names vs titles.** A board's file name, a folder's `name`, a scene's directory are
139
+ IDENTITIES: slugs you address, that publish.json, URLs and comment threads hold, and
140
+ that never move on a rename. `title` is what humans see - free text (casing,
141
+ punctuation, emoji) on the board JSON, the `_folders.json` entry, the brief's front
142
+ matter; frames have `meta.title`. When the human says "the Checkout A/B board", the
143
+ manifest maps that title to its slug. Rename a slug only when asked, as one refactor.
144
+ - **Descriptions.** Every object takes one optional `description`: one sentence, what it
145
+ is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
146
+ in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
147
+ entry. Scene: the FIRST line of its `_brief.md`. Frame: `meta.description`. Write it
148
+ when you create the thing; keep it true when the state changes (retired, winning
149
+ direction, superseded); before a session ends, re-read the manifest and fix any
150
+ description your session made false. That is how the next session orients in one read.
135
151
  - Component galleries: create design/components/<name>/variants.tsx rendering each variant
136
152
  and each state (default / hover-styled / focus / disabled / loading) of one ui component.
137
153
 
@@ -159,8 +175,12 @@ edges; a variant-group name is one indivisible atom). **The default composition
159
175
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
160
176
  band only when you can say why the eye should move down, and then with generous
161
177
  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).
178
+ every curated board carries one. Boards can sit in **folders** (one level): put
179
+ `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
180
+ and ranks them - the human creates, renames, and drags folders in the sidebar too, so
181
+ run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
182
+ creating a board, organising boards, or publishing anything, read instructions/boards.md
183
+ (the layout grammar, file format, folders and their moves, publishing rules).
164
184
 
165
185
  A round of feedback on a scene the human has already reviewed starts with a
166
186
  **version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
@@ -10,9 +10,24 @@ 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",
14
+ "description": "Cart step, direction A vs B side by side - B is the current favourite",
13
15
  "nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
14
16
  ```
15
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`.
27
+ - `description` - one sentence on what the board is for and where it stands. It is
28
+ how a later session (or the human's next agent) knows this board without opening
29
+ it; it lands in design/manifest.json. Write it at creation, keep it true.
30
+
16
31
  - The same frame may appear on many boards, or twice on one (add `"w"`/`"h"` on a
17
32
  node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison row: same `y`,
18
33
  increasing `x`).
@@ -23,8 +38,9 @@ viewport and lays it out:
23
38
  orienting board (an overview or the primary flow) - never a giant one. Boards
24
39
  without an `order` sort after the ranked ones, by name. Set `order` deliberately on
25
40
  every curated board; it is the first impression. The human can also drag-reorder boards
26
- in the sidebar (which rewrites `order`) and rename one from its right-click menu - so
27
- 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.
28
44
  - `auto: false` boards show exactly their list. `all-scenes` is auto-managed (it holds
29
45
  EVERY frame, so it is the heavy one) and always sinks to the BOTTOM of the switcher -
30
46
  never the landing board, and never write its file.
@@ -44,6 +60,79 @@ viewport and lays it out:
44
60
  version, oldest at the top. Winners live on the feature boards; the archive
45
61
  answers "what did we try?" and "what did it look like before?".
46
62
 
63
+ ## Folders - organising the sidebar
64
+
65
+ Boards can sit in folders, one level deep (folders hold boards, never folders).
66
+ Files are the truth, and two files carry it:
67
+
68
+ - **Membership lives on the board**: `"folder": "research"` in the board file, next
69
+ to `order`. `order` then ranks the board among its folder siblings (root boards and
70
+ folders share the root sequence). Same grammar as board names
71
+ (`^[a-z0-9][a-z0-9-]*$`); an invalid value means top level. `all-scenes` never
72
+ lives in a folder.
73
+ - **Folders live in `design/boards/_folders.json`** - the underscore marks it as
74
+ infrastructure, never a board:
75
+
76
+ ```json
77
+ { "version": 1, "folders": [
78
+ { "name": "research", "order": 1, "title": "R&D", "description": "The thinking behind the live boards - specs, flows, references" },
79
+ { "name": "archive", "order": 3, "description": "Retired directions and scene versions, oldest first" } ] }
80
+ ```
81
+
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.
86
+
87
+ It exists so an EMPTY folder can exist and so a folder has a rank at the root.
88
+ A folder a board names but the registry lacks is still real (it sorts after the
89
+ ranked items, by name) - two boards with `"folder": "research"` make a Research
90
+ folder on their own. A malformed registry is an error the canvas shows, not an
91
+ empty one - fix it, never delete it.
92
+
93
+ **Look before you organise: `npx marver boards`** prints the sidebar as the files say
94
+ it is - every folder (and whether it is empty or only implied by its boards), every
95
+ board in reading order with its `order`, the landing board, and whether the registry
96
+ exists (`--json` for the tree). Run it before any of the moves below; the human may
97
+ have rearranged things since you last looked, and their arrangement stands.
98
+
99
+ The moves, each a file edit, so the files always agree:
100
+ - **Create** a folder: add `{ "name": "<slug>", "order": <n>, "description": "…" }`
101
+ to the registry's `folders` (create the file if absent) - or just point a board at it.
102
+ - **Move a board in**: write `"folder": "<slug>"` on the board and give it an `order`
103
+ among that folder's boards. **Move it out**: delete the `folder` field and give it
104
+ an `order` among the top-level items.
105
+ - **Rank** folders and boards: `order` on the board (among its siblings) and on the
106
+ registry entry (among the top-level items). Renumber the siblings you touch.
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.
113
+ - **Delete** a folder: remove `folder` from every member, then its registry entry.
114
+ Folders organise, never own: deleting one never deletes a board.
115
+ - The **landing board** is the first board in sidebar order, folders included -
116
+ rank a folder first and its first board opens the canvas.
117
+
118
+ Use folders proactively, the way a tidy studio would: a canvas past six or eight
119
+ boards wants grouping - the live feature boards at the top level, `research` /
120
+ `specs` for the thinking, `decks` for slides, `archive` for history and versions
121
+ last. Propose the grouping in one sentence and do it; keep folder names short and
122
+ plain.
123
+
124
+ The human does all of this too - from the sidebar: New folder (right-click the Boards
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
128
+ `folder`) on the boards it touches and the registry - the shell owns those fields
129
+ while the canvas is open, exactly as it owns `order`; write membership and new
130
+ folders freely, and never rewrite an arrangement the human just made. The shell
131
+ refuses a write that would overwrite an edit it has not seen (your file write and
132
+ the human's drag can never silently erase each other), so read a board file before
133
+ you rewrite it. Published canvases show the folders of the published boards only; a
134
+ folder with nothing published never reaches the bundle.
135
+
47
136
  ## The default composition: one horizontal band
48
137
 
49
138
  A board reads like a page: left to right first, down only for a reason. The
@@ -144,6 +144,10 @@ app, and the human attributes the fault to your frame, not to a library.
144
144
 
145
145
  ## Frame law
146
146
 
147
+ - Every frame carries `meta.description` - one sentence, what the screen is for and
148
+ its state when that is not obvious ("Filled state of the orders table, current
149
+ direction"). It reaches design/manifest.json; the next session reads it instead
150
+ of the file.
147
151
  - Frames are made of the app's real components and tokens. Rebuilding a lookalike of
148
152
  an existing component inside a frame is a defect.
149
153
  - Repeated and semantic values (colors, type sizes, radii, the spacing rhythm)
@@ -40,12 +40,29 @@ to the SURFACE, not the product.
40
40
 
41
41
  Create `design/scenes/<scene>/_brief.md`: audience + scene, the one job, mode, the
42
42
  flow as a numbered list, content sources, out-of-scope. Ten lines, not a document.
43
- Show it. Get the nod.
43
+ **Its first non-blank line is the scene's one-sentence description** - purpose and state,
44
+ e.g. `# Checkout - the buyer's path from cart to receipt (v2, after the pricing pivot)`.
45
+ That line lands in `design/manifest.json` as the scene's `description`, so a later
46
+ session reads it without opening the brief; keep it true as the scene moves on (a
47
+ scene that only needs a gist - a version snapshot, an archive - gets a one-line brief).
48
+ A scene's directory is its identity (every frame id starts with it); what humans SEE
49
+ for it is `title` in the brief's YAML front matter - optional, free text - the sidebar
50
+ Title-Cases the directory otherwise (`checkout` → "Checkout", but `mvp` → "Mvp"):
51
+
52
+ ```md
53
+ ---
54
+ title: "Checkout (v2)"
55
+ ---
56
+ # Checkout - the buyer's path from cart to receipt (v2, after the pricing pivot)
57
+ ```
58
+
59
+ The human's Rename on a scene writes exactly that line; leave the block alone and
60
+ never rename the directory for a title. Show the brief. Get the nod.
44
61
 
45
62
  **Unattended?** When the human is away or has said "don't ask", the interview and
46
63
  the nod convert to obligations, not blockers: answer the five questions yourself
47
- from the repo and reasonable product judgment, mark the brief `UNCONFIRMED` at the
48
- top, proceed - and surface the brief FIRST when the human returns. Never stall on
64
+ from the repo and reasonable product judgment, mark the brief `UNCONFIRMED` in its
65
+ first line (`# Checkout - … (UNCONFIRMED)`), proceed - and surface the brief FIRST when the human returns. Never stall on
49
66
  an absent human; never hide that the brief was self-answered.
50
67
 
51
68
  ## 4. Align on flow with a diagram frame
@@ -113,11 +113,12 @@ The human picks a direction; then, in one pass:
113
113
  1. **The winner takes the clean name.** Drop its letter prefix (or promote it
114
114
  over the original file); update goto targets pointing at old ids.
115
115
  2. **The losers move to `design/scenes/archive/`** - never deleted, RELABELED:
116
- filename `<feature>-<direction>.tsx`, meta.title saying exactly what it was
117
- and why it retired, e.g.
118
- `{ title: "Routines editor - guided steps (retired: sentence canvas won, sharper mental model)" }`.
116
+ filename `<feature>-<direction>.tsx`, meta.title saying what it was and
117
+ meta.description saying why it retired, e.g.
118
+ `{ title: "Routines editor - guided steps", description: "Retired: the sentence canvas won - sharper mental model" }`.
119
119
  A one-line comment at the top of the file carries any longer why. That
120
- sentence is the learning - write it while the reason is fresh.
120
+ sentence is the learning - write it while the reason is fresh; it reaches
121
+ design/manifest.json, so the next session knows without opening the file.
121
122
  3. **The `archive` board stays clean and organized:** a curated board over the
122
123
  archive scene, tidied with a layout recipe, grouped by feature. Anyone
123
124
  opening it should know what every frame was without asking.
@@ -26,6 +26,10 @@ however late it surfaced.
26
26
  6. **States exist**: for each screen with meaningful states, the empty / error /
27
27
  loading siblings are present and reachable.
28
28
  7. **Craft floor**: one pass over craft.md's Verify list against the RENDERED frames.
29
+ 8. **Descriptions true**: read design/manifest.json once more - every board, folder,
30
+ scene and frame this session touched carries a `description` that is still true
31
+ (state words above all: retired, winning, superseded). The next session orients
32
+ from that file; a stale sentence there costs it more than a missing one.
29
33
 
30
34
  ## Honesty rules
31
35