@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.
- package/CHANGELOG.md +101 -0
- package/README.md +2 -1
- package/dist/boards-BmxcT3Lc.mjs +290 -0
- package/dist/boards-PuVzw5Wp.mjs +62 -0
- package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
- package/dist/cli.mjs +33 -13
- package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
- package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +45 -259
- package/src/client/shell/BoardList.tsx +438 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +156 -51
- package/src/client/shell/styles.css +40 -4
- package/src/shared/board-tree.ts +343 -0
- package/templates/AGENTS-embedded.md +25 -5
- package/templates/AGENTS-studio.md +25 -5
- package/templates/instructions/boards.md +91 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +20 -3
- package/templates/instructions/iterate.md +5 -4
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +1 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- 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
|
|
135
|
-
|
|
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.
|
|
163
|
-
|
|
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
|
|
134
|
-
|
|
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.
|
|
163
|
-
|
|
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`)
|
|
27
|
-
your ranking is a starting
|
|
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
|
-
|
|
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`
|
|
48
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
`{ title: "Routines editor - guided steps
|
|
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
|
|