@marver-design/marver 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -133,8 +133,15 @@ Report where the request came from: chat requests get chat replies; only comment
133
133
  ## Orientation
134
134
  - design/manifest.json is the canvas with its purpose: the project (name, description),
135
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.
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.
138
145
  - **Descriptions.** Every object takes one optional `description`: one sentence, what it
139
146
  is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
140
147
  in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
@@ -132,8 +132,15 @@ Report where the request came from: chat requests get chat replies; only comment
132
132
  ## Orientation
133
133
  - design/manifest.json is the canvas with its purpose: the project (name, description),
134
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.
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.
137
144
  - **Descriptions.** Every object takes one optional `description`: one sentence, what it
138
145
  is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
139
146
  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
@@ -45,7 +45,19 @@ e.g. `# Checkout - the buyer's path from cart to receipt (v2, after the pricing
45
45
  That line lands in `design/manifest.json` as the scene's `description`, so a later
46
46
  session reads it without opening the brief; keep it true as the scene moves on (a
47
47
  scene that only needs a gist - a version snapshot, an archive - gets a one-line brief).
48
- Show it. Get the nod.
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.
49
61
 
50
62
  **Unattended?** When the human is away or has said "don't ask", the interview and
51
63
  the nod convert to obligations, not blockers: answer the five questions yourself