@marver-design/marver 0.19.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +8 -10
  3. package/dist/{bake-kaf5kGZ7.mjs → bake-BID6mo-N.mjs} +1 -1
  4. package/dist/{boards-BmxcT3Lc.mjs → boards-BwiDAmPf.mjs} +95 -48
  5. package/dist/{boards-PuVzw5Wp.mjs → boards-DnLewfj8.mjs} +20 -11
  6. package/dist/{build-7ed5H2vT.mjs → build-C7MqQ7hq.mjs} +18 -8
  7. package/dist/cli.mjs +19 -13
  8. package/dist/{daemon-DbHvLQUL.mjs → daemon-CRZFpl6K.mjs} +1 -1
  9. package/dist/{dev-D3mP2x27.mjs → dev-BNZF4Mup.mjs} +5 -5
  10. package/dist/{init-BQYCS3EU.mjs → init-C34BY3R4.mjs} +34 -33
  11. package/dist/{manifest-B01PSyDc.mjs → manifest-mMfUhPtL.mjs} +8 -5
  12. package/dist/{plugin-DI-7NAnx.mjs → plugin-omHLCn91.mjs} +34 -26
  13. package/dist/{poster-DNh6N27C.mjs → poster-BvxiAzy1.mjs} +1 -1
  14. package/dist/{publish-bakes-Dp-ZFk3d.mjs → publish-bakes-BqzAAa3w.mjs} +8 -2
  15. package/dist/{shot-DMDvDbeP.mjs → shot-DswS4iRK.mjs} +7 -7
  16. package/docs/live-jam.md +1 -1
  17. package/docs/slides.md +89 -89
  18. package/docs/sticky-notes.md +9 -0
  19. package/package.json +1 -1
  20. package/src/client/const.ts +27 -9
  21. package/src/client/content/chart.tsx +8 -8
  22. package/src/client/content/index.tsx +5 -4
  23. package/src/client/content/slide.tsx +26 -201
  24. package/src/client/frame-host/main.tsx +10 -0
  25. package/src/client/shell/BoardList.tsx +111 -52
  26. package/src/client/shell/Comments.tsx +12 -4
  27. package/src/client/shell/Play.tsx +26 -14
  28. package/src/client/shell/Toolbar.tsx +7 -5
  29. package/src/client/shell/canvas/FrameNode.tsx +16 -2
  30. package/src/client/shell/store.ts +8 -8
  31. package/src/client/shell/styles.css +10 -9
  32. package/src/client/stage/main.tsx +56 -7
  33. package/src/shared/board-tree.ts +271 -140
  34. package/templates/AGENTS-embedded.md +3 -3
  35. package/templates/AGENTS-studio.md +3 -3
  36. package/templates/instructions/boards.md +47 -22
  37. package/templates/instructions/reference/deck-layouts.md +153 -199
  38. package/templates/instructions/reference/deck-story.md +6 -6
  39. package/templates/instructions/slides.md +275 -383
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Board folders - the pure tree shared by the sidebar, the dev API, the build and the
3
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.
4
+ * board file - one slug, the folder it sits in directly), ranked among its siblings by
5
+ * `order`, and `design/boards/_folders.json` says which folders exist, where they rank, and
6
+ * which folder holds which (`parent`). Two levels: a folder holds boards and folders, a
7
+ * folder inside a folder (a sub-folder) holds boards only. `all-scenes` never enters the
8
+ * tree - callers pin it last.
7
9
  */
8
10
 
9
11
  /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
@@ -17,11 +19,12 @@ export const FOLDERS_FILE = '_folders.json'
17
19
  * off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
18
20
  export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
19
21
 
20
- export type Folder = { kind: 'folder'; name: string; boards: string[]; title?: string; description?: string }
22
+ /** A folder and what it holds, in order: boards, and - in a top-level folder only - sub-folders. */
23
+ export type Folder = { kind: 'folder'; name: string; items: TreeItem[]; title?: string; description?: string }
21
24
  export type TreeItem = { kind: 'board'; name: string } | Folder
22
25
 
23
26
  export interface BoardRow { name: string; order?: number; folder?: string; title?: string }
24
- export interface FolderRow { name: string; order?: number; title?: string; description?: string }
27
+ export interface FolderRow { name: string; order?: number; parent?: string; title?: string; description?: string }
25
28
 
26
29
  /** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
27
30
  export const DESCRIPTION_MAX = 300
@@ -50,13 +53,25 @@ const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinit
50
53
  /** A folder's title and description, present only when set. */
51
54
  const folderExtras = (it: { title?: string; description?: string }) => ({ ...(it.title ? { title: it.title } : {}), ...(it.description ? { description: it.description } : {}) })
52
55
 
56
+ /** The registry versions this code reads. Version 2 is written only when a folder has a
57
+ * `parent`, so a flat registry stays readable by every Marver; an older Marver refuses a
58
+ * version-2 file as malformed instead of rewriting it without its nesting. */
59
+ export const REGISTRY_VERSION_FLAT = 1
60
+ export const REGISTRY_VERSION_NESTED = 2
61
+ /** The tree-write protocol a shell speaks. A shell that predates nesting reads a nested
62
+ * registry flat (it ignores `parent`) and would post that flat tree back with a perfectly
63
+ * current hash - the hash proves freshness, not understanding - so the server refuses a write
64
+ * without this marker whenever the registry on disk nests. */
65
+ export const TREE_PROTOCOL = 2
66
+
53
67
  /** 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. */
68
+ * malformed registry is an ERROR the human must fix (silently reading it as empty, or
69
+ * flattening a broken nesting, would let the next drag overwrite their folders), while a
70
+ * missing file is simply no folders. */
56
71
  export function parseFolders(raw: unknown): FolderRow[] | string {
57
72
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
58
73
  const { version, folders } = raw as { version?: unknown; folders?: unknown }
59
- if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
74
+ if (version !== undefined && version !== REGISTRY_VERSION_FLAT && version !== REGISTRY_VERSION_NESTED) return `unsupported version ${String(version)}`
60
75
  if (!Array.isArray(folders)) return 'expected a "folders" array'
61
76
  const out: FolderRow[] = []
62
77
  const seen = new Set<string>()
@@ -66,66 +81,92 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
66
81
  if (seen.has(name)) return `folder "${name}" is listed twice`
67
82
  seen.add(name)
68
83
  const o = (f as { order?: unknown }).order
84
+ const p = (f as { parent?: unknown }).parent
85
+ if (p !== undefined && !isBoardName(p)) return `folder "${name}" has an invalid parent - a folder name`
69
86
  const t = readTitle((f as { title?: unknown }).title)
70
87
  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 } : {}) })
88
+ out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(p !== undefined ? { parent: p } : {}), ...(t ? { title: t } : {}), ...(d ? { description: d } : {}) })
89
+ }
90
+ const byName = new Map(out.map((f) => [f.name, f]))
91
+ for (const f of out) {
92
+ if (f.parent === undefined) continue
93
+ if (version !== REGISTRY_VERSION_NESTED) return `folder "${f.name}" names a parent - nested folders need "version": 2`
94
+ if (f.parent === f.name) return `folder "${f.name}" cannot be its own parent`
95
+ const p = byName.get(f.parent)
96
+ if (!p) return `folder "${f.name}" names an unknown parent "${f.parent}"`
97
+ if (p.parent !== undefined) return `folder "${f.name}" would sit three levels deep - folders nest one level only`
72
98
  }
73
99
  return out
74
100
  }
75
101
 
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. */
102
+ /** Sidebar order from the files. At every level the boards and folders there share one
103
+ * sequence, ranked by `order`, then kind (board before folder), then name; unranked sorts
104
+ * after ranked. The root holds root boards and top-level folders (registered without a
105
+ * parent, or implied by a board that names an unregistered folder); a top-level folder holds
106
+ * its boards and its sub-folders; a sub-folder holds its boards. A folder's title and
107
+ * description ride on its item (they live in the registry a tree write rewrites); a board's
108
+ * title stays with its row - it lives in the board's own file. */
81
109
  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)) }
110
+ const reg = new Map<string, FolderRow>()
111
+ for (const f of folders) if (isBoardName(f.name) && !reg.has(f.name)) reg.set(f.name, f)
112
+ // a parent the registry does not hold as a top-level folder leaves the child top-level -
113
+ // parseFolders refuses such files; this keeps buildTree total for any rows it is handed
114
+ const parentOf = (n: string): string | undefined => {
115
+ const p = reg.get(n)?.parent
116
+ return p !== undefined && p !== n && reg.has(p) && reg.get(p)!.parent === undefined ? p : undefined
117
+ }
85
118
  const members = new Map<string, BoardRow[]>()
119
+ const implied: string[] = []
86
120
  const rootBoards: BoardRow[] = []
87
121
  for (const b of boards) {
88
122
  if (!isBoardName(b.name) || b.name === 'all-scenes') continue
89
123
  const folder = isBoardName(b.folder) ? b.folder : undefined
90
124
  if (!folder) { rootBoards.push(b); continue }
91
- if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
125
+ if (!reg.has(folder) && !implied.includes(folder)) implied.push(folder) // implied by the board alone: always top-level
92
126
  const list = members.get(folder) ?? []
93
127
  list.push(b)
94
128
  members.set(folder, list)
95
129
  }
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)
130
+ type Ranked = { item: TreeItem; order: number | undefined }
131
+ const sorted = (xs: Ranked[]): TreeItem[] =>
132
+ xs.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)).map((r) => r.item)
133
+ const boardsHere = (name: string | null): Ranked[] =>
134
+ (name === null ? rootBoards : members.get(name) ?? []).map((b) => ({ item: { kind: 'board', name: b.name }, order: b.order }))
135
+ const folder = (name: string, kids: Ranked[]): Ranked =>
136
+ ({ item: { kind: 'folder', name, items: sorted(kids), ...folderExtras(reg.get(name) ?? {}) }, order: reg.get(name)?.order })
137
+ const subsOf = (top: string) => [...reg.keys()].filter((n) => parentOf(n) === top)
138
+ const tops = [...[...reg.keys()].filter((n) => !parentOf(n)), ...implied]
139
+ return sorted([
140
+ ...boardsHere(null),
141
+ ...tops.map((t) => folder(t, [...boardsHere(t), ...subsOf(t).map((s) => folder(s, boardsHere(s)))])),
142
+ ])
107
143
  }
108
144
 
109
- /** Every board in reading order - the order the switchers and the landing pick use. */
145
+ /** Every board in reading order, depth-first - the order the switchers and the landing pick use. */
110
146
  export function flatten(tree: TreeItem[]): string[] {
111
147
  const out: string[] = []
112
- for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
148
+ const walk = (items: TreeItem[]) => { for (const it of items) { if (it.kind === 'board') out.push(it.name); else walk(it.items) } }
149
+ walk(tree)
113
150
  return out
114
151
  }
115
152
 
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 }
153
+ /** The wire shape of a tree write (`POST boards/reorder`): plain strings for boards,
154
+ * `{ folder, items }` for folders, `items` holding boards and - one level down - folders.
155
+ * What the sidebar posts and the server validates. A folder's title and description ride
156
+ * with it (they live in the registry the write rewrites); a board's title lives in its own
157
+ * file and never rides the tree. A one-level `{ folder, boards }` item (an older shell) is
158
+ * still read. */
159
+ export type WireItem = string | { folder: string; items: WireItem[]; title?: string; description?: string }
160
+ type WireIn = string | { folder: string; items?: WireIn[]; boards?: string[]; title?: string; description?: string }
121
161
  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) }))
162
+ tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, items: toWire(it.items), ...folderExtras(it) }))
163
+ export const wireKids = (w: Exclude<WireIn, string>): WireIn[] => w.items ?? w.boards ?? []
164
+ export const fromWire = (wire: WireIn[]): TreeItem[] =>
165
+ wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, items: fromWire(wireKids(w)), ...folderExtras(w) }))
125
166
 
126
167
  /** 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. */
168
+ * every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, folders
169
+ * two levels deep at most, bounded. */
129
170
  export const TREE_MAX_BOARDS = 200
130
171
  export const TREE_MAX_FOLDERS = 50
131
172
  export function validateWire(wire: unknown): string | null {
@@ -137,18 +178,27 @@ export function validateWire(wire: unknown): string | null {
137
178
  boards.add(n)
138
179
  return null
139
180
  }
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 }
181
+ const walk = (list: unknown[], depth: number): string | null => {
182
+ for (const w of list) {
183
+ if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
184
+ if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
185
+ if (depth >= 2) return 'folders nest one level only'
186
+ const { folder, items, boards: legacy, title, description } = w as { folder?: unknown; items?: unknown; boards?: unknown; title?: unknown; description?: unknown }
187
+ if (!isBoardName(folder)) return 'invalid folder name in tree'
188
+ if (title !== undefined && (typeof title !== 'string' || Array.from(title).length > TITLE_MAX)) return 'invalid folder title'
189
+ if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
190
+ if (folders.has(folder)) return `folder "${folder}" appears twice`
191
+ folders.add(folder)
192
+ const kids = items ?? legacy
193
+ if (!Array.isArray(kids)) return 'invalid folder in tree'
194
+ if (items === undefined && kids.some((k) => typeof k !== 'string')) return 'invalid folder in tree' // a one-level item holds boards only
195
+ const e = walk(kids, depth + 1)
196
+ if (e) return e
197
+ }
198
+ return null
151
199
  }
200
+ const e = walk(wire, 0)
201
+ if (e) return e
152
202
  if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
153
203
  return null
154
204
  }
@@ -160,26 +210,62 @@ export function slugify(raw: string): string {
160
210
  return isBoardName(s) ? s : ''
161
211
  }
162
212
 
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)
213
+ // ---- reading the tree ----
171
214
 
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 } }
215
+ /** A deep copy: mutations work on it and the caller's tree stays as it was (a folder's
216
+ * title and description ride along). */
217
+ export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, items: cloneTree(it.items) }))
218
+ /** Every folder with the folder it sits in (null = the root), top-level folders first in
219
+ * reading order, each followed by its sub-folders. */
220
+ export function folderEntries(t: TreeItem[]): { folder: Folder; parent: string | null }[] {
221
+ const out: { folder: Folder; parent: string | null }[] = []
176
222
  for (const it of t) {
177
223
  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 } }
224
+ out.push({ folder: it, parent: null })
225
+ for (const k of it.items) if (k.kind === 'folder') out.push({ folder: k, parent: it.name })
226
+ }
227
+ return out
228
+ }
229
+ /** A folder anywhere in the tree. */
230
+ export const folderIn = (t: TreeItem[], name: string): Folder | undefined => folderEntries(t).find((e) => e.folder.name === name)?.folder
231
+ /** The folder a folder sits in - null for a top-level folder (or one that is not there). */
232
+ export const parentOf = (t: TreeItem[], name: string): string | null => folderEntries(t).find((e) => e.folder.name === name)?.parent ?? null
233
+ /** The folder a board sits in directly, at either level - null at the root. */
234
+ export function folderOf(t: TreeItem[], board: string): string | null {
235
+ for (const { folder } of folderEntries(t)) if (folder.items.some((k) => k.kind === 'board' && k.name === board)) return folder.name
236
+ return null
237
+ }
238
+ export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
239
+ export const foldersIn = (t: TreeItem[]): string[] => folderEntries(t).map((e) => e.folder.name)
240
+ /** Does this folder hold folders? Such a folder can only ever sit at the root. */
241
+ export const hasSubfolders = (f: Folder): boolean => f.items.some((k) => k.kind === 'folder')
242
+ /** The list a slot lives in: the root (null) or a folder's items. */
243
+ export const listIn = (t: TreeItem[], list: string | null): TreeItem[] | undefined => (list === null ? t : folderIn(t, list)?.items)
244
+ /** Where an item sits in a list, or -1. */
245
+ export const indexIn = (t: TreeItem[], list: string | null, kind: TreeItem['kind'], name: string): number =>
246
+ listIn(t, list)?.findIndex((it) => it.kind === kind && it.name === name) ?? -1
247
+ export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => indexIn(t, null, kind, name)
248
+ /** How deep a list sits: the root 0, a top-level folder's items 1, a sub-folder's items 2. */
249
+ export const depthOf = (t: TreeItem[], list: string | null): number => (list === null ? 0 : parentOf(t, list) === null ? 1 : 2)
250
+
251
+ /** Remove an item wherever it sits; returns the list it left (root = null), its index there
252
+ * and the item itself. */
253
+ export function takeItem(t: TreeItem[], kind: TreeItem['kind'], name: string): { list: string | null; index: number; item: TreeItem } | null {
254
+ const lists: (string | null)[] = [null, ...foldersIn(t)]
255
+ for (const list of lists) {
256
+ const items = listIn(t, list)!
257
+ const i = items.findIndex((it) => it.kind === kind && it.name === name)
258
+ if (i >= 0) { const [item] = items.splice(i, 1); return { list, index: i, item: item! } }
180
259
  }
181
260
  return null
182
261
  }
262
+ /** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
263
+ export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
264
+ const r = takeItem(t, 'board', board)
265
+ return r ? { list: r.list, index: r.index } : null
266
+ }
267
+
268
+ // ---- drag and drop ----
183
269
 
184
270
  /** What is being dragged, and where it may land: a slot in a list (root = null) or the
185
271
  * inside of a folder (appended at its end). */
@@ -187,120 +273,160 @@ export type Drag = { kind: TreeItem['kind']; name: string }
187
273
  export type Drop = { list: string | null; index: number } | { into: string }
188
274
 
189
275
  /** 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. */
276
+ * the pinned `all-scenes` row included (it is the root's end). `parent` is the folder the
277
+ * row sits in (null = the root) and `depth` that list's depth (0 root, 1 a top-level
278
+ * folder's items, 2 a sub-folder's). `open` is a folder's disclosure; `left` is the row's
279
+ * left edge, from which the indent of each level is measured. */
280
+ export interface Row { kind: TreeItem['kind']; name: string; parent: string | null; depth: number; open?: boolean; top: number; bottom: number; left: number }
281
+ /** Px each level of nesting indents its rows; the seams inside draw from there too. */
194
282
  export const INDENT = 28
195
283
 
284
+ /** How deep the dragged item may land: a board anywhere (2), a folder without sub-folders
285
+ * inside a top-level folder (1), a folder holding sub-folders at the root only (0). */
286
+ function landingDepth(t: TreeItem[], d: Drag): number {
287
+ if (d.kind === 'board') return 2
288
+ const f = folderIn(t, d.name)
289
+ return f && !hasSubfolders(f) ? 1 : 0
290
+ }
291
+
196
292
  /** The drop target for a pointer at (x, y) over the rendered rows - never null inside the
197
293
  * 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. */
294
+ * showed. INTO: the middle band of a folder header (a closed one: its lower band too), when
295
+ * what is dragged may sit inside it. Otherwise the nearest gap by row midlines. A gap inside
296
+ * a list belongs to it. A gap where lists END - after the last row of a folder, or under an
297
+ * open empty folder - is shared by every level that ends there, down to the level of the row
298
+ * below; the row under the pointer decides: still over the row above, the deepest level whose
299
+ * indent the pointer reaches; over the row below (or past it), that row's own level. A folder
300
+ * never lands inside itself; one that holds sub-folders moves between root items only, each
301
+ * root item one block. */
206
302
  export function resolveDrop(t: TreeItem[], d: Drag, rows: Row[], x: number, y: number): Drop | null {
207
303
  const mid = (r: { top: number; bottom: number }) => (r.top + r.bottom) / 2
208
- if (d.kind === 'folder') {
304
+ const deepest = landingDepth(t, d)
305
+ if (deepest === 0) {
209
306
  const blocks: { top: number; bottom: number }[] = []
210
307
  for (const r of rows) {
211
- if (r.parent && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
308
+ if (r.depth > 0 && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
212
309
  blocks.push({ top: r.top, bottom: r.bottom })
213
310
  }
214
311
  let g = 0
215
312
  for (const b of blocks) if (y >= mid(b)) g++
216
313
  return { list: null, index: Math.min(g, t.length) }
217
314
  }
218
- for (const r of rows) {
219
- if (r.kind !== 'folder' || y < r.top || y >= r.bottom) continue
315
+ // what a dragged folder holds is never a place to land; its own header stays in the
316
+ // geometry, so a wobble over it resolves to its own slot - a no-op, never an eviction
317
+ const rs = d.kind === 'folder' ? rows.filter((r) => r.parent !== d.name && !(r.parent && parentOf(t, r.parent) === d.name)) : rows
318
+ for (const r of rs) {
319
+ if (r.kind !== 'folder' || (d.kind === 'folder' && r.name === d.name) || y < r.top || y >= r.bottom || r.depth + 1 > deepest) continue
220
320
  const f = (y - r.top) / (r.bottom - r.top)
221
321
  if (f >= 0.25 && (f < 0.75 || !r.open)) return { into: r.name }
222
322
  }
223
323
  let g = 0
224
- for (const r of rows) if (y >= mid(r)) g++
225
- const above = rows[g - 1], below = rows[g]
324
+ for (const r of rs) if (y >= mid(r)) g++
325
+ const above = rs[g - 1], below = rs[g]
226
326
  if (!above) return { list: null, index: 0 }
227
327
  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 })
328
+ type Slot = { list: string | null; index: number; depth: number }
329
+ // the deepest slot the gap can mean: inside an open folder, under its header - or right after the row above, in its own list
330
+ let first: Slot
331
+ if (above.kind === 'folder' && above.open && !(d.kind === 'folder' && above.name === d.name)) first = { list: above.name, index: 0, depth: above.depth + 1 }
332
+ else {
333
+ const i = indexIn(t, above.parent, above.kind, above.name)
334
+ if (i < 0) return null
335
+ first = { list: above.parent, index: i + 1, depth: above.depth }
336
+ }
337
+ // then, level by level up to the root: right after the folder that holds the slot before
338
+ const chain: Slot[] = [first]
339
+ for (let s = first; s.list !== null;) {
340
+ const up = parentOf(t, s.list)
341
+ const i = indexIn(t, up, 'folder', s.list)
342
+ if (i < 0) return null
343
+ s = { list: up, index: i + 1, depth: s.depth - 1 }
344
+ chain.push(s)
236
345
  }
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 })
346
+ const floor = below ? below.depth : 0
347
+ const slots = chain.filter((s) => s.depth >= floor && s.depth <= deepest)
348
+ const strip = (s: Slot): Drop => ({ list: s.list, index: s.index })
349
+ if (!slots.length) { const s = chain.find((c) => c.depth <= deepest); return s ? strip(s) : null }
350
+ if (slots.length === 1) return strip(slots[0]!)
351
+ if (y < above.bottom) for (const s of slots) if (x >= above.left + s.depth * INDENT) return strip(s)
352
+ return strip(slots[slots.length - 1]!)
242
353
  }
243
354
 
244
355
  /** 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. */
356
+ * the folder an item already sits in means "to its end" - a no-op only when it is last. */
246
357
  export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
247
358
  if ('into' in target) {
248
- if (d.kind !== 'board') return true
249
359
  const f = folderIn(t, target.into)
250
- return !!f && f.boards[f.boards.length - 1] === d.name
360
+ const last = f?.items[f.items.length - 1]
361
+ return !!last && last.kind === d.kind && last.name === d.name
251
362
  }
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
363
+ const from = indexIn(t, target.list, d.kind, d.name)
256
364
  return from >= 0 && (target.index === from || target.index === from + 1)
257
365
  }
258
366
 
259
- /** The tree after a drop, or null when the drop is impossible on this tree. */
367
+ /** The tree after a drop, or null when the drop is impossible on this tree: a folder into
368
+ * itself, into a sub-folder, or - when it holds sub-folders - into any folder. */
260
369
  export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
261
370
  const next = cloneTree(tree)
371
+ const dest = 'into' in target ? target.into : target.list
262
372
  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)
373
+ const f = folderIn(next, d.name)
274
374
  if (!f) return null
275
- f.boards.push(d.name)
276
- return next
375
+ if (dest !== null && (dest === d.name || hasSubfolders(f) || !folderIn(next, dest) || parentOf(next, dest) !== null)) return null
277
376
  }
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)
377
+ const src = takeItem(next, d.kind, d.name)
378
+ if (!src) return null
379
+ const list = listIn(next, dest)
380
+ if (!list) return null
381
+ const to = 'into' in target ? list.length : src.list === dest && target.index > src.index ? target.index - 1 : target.index // removing the item shifts later slots left
382
+ list.splice(Math.min(to, list.length), 0, src.item)
283
383
  return next
284
384
  }
285
385
 
286
- /** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
386
+ // ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
387
+
388
+ /** Move a board into a folder at either level (at its end), or to the root at `atRoot`
389
+ * (default: the end). */
287
390
  export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
288
391
  const next = cloneTree(tree)
289
392
  if (!takeBoard(next, board)) return null
290
393
  if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
291
394
  const f = folderIn(next, folder)
292
395
  if (!f) return null
293
- f.boards.push(board)
396
+ f.items.push({ kind: 'board', name: board })
397
+ return next
398
+ }
399
+
400
+ /** Move a folder to the root at `atRoot` (default: the end) - "Move to top level" for a sub-folder. */
401
+ export function moveFolderToRoot(tree: TreeItem[], name: string, atRoot?: number): TreeItem[] | null {
402
+ const next = cloneTree(tree)
403
+ const src = takeItem(next, 'folder', name)
404
+ if (!src) return null
405
+ // an explicit slot was measured before the folder left the root; the default end was not
406
+ const at = atRoot === undefined ? next.length : src.list === null && atRoot > src.index ? atRoot - 1 : atRoot
407
+ next.splice(Math.min(at, next.length), 0, src.item)
294
408
  return next
295
409
  }
296
410
 
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 {
411
+ /** Can this list hold a folder? The root and a top-level folder can; a sub-folder, or a
412
+ * folder that is not there, cannot. */
413
+ export const holdsFolders = (t: TreeItem[], parent: string | null): boolean => parent === null || (!!folderIn(t, parent) && parentOf(t, parent) === null)
414
+
415
+ /** A new folder at `index` in `parent`'s items (null = the root), holding `board` (pulled
416
+ * from wherever it sat) when given. Only the root and top-level folders hold folders. */
417
+ export function createFolder(tree: TreeItem[], name: string, index: number, board?: string, title?: string, parent: string | null = null): TreeItem[] | null {
299
418
  if (foldersIn(tree).includes(name)) return null
300
419
  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 } : {}) })
420
+ if (!holdsFolders(next, parent)) return null
421
+ const items: TreeItem[] = []
422
+ if (board) {
423
+ const src = takeItem(next, 'board', board)
424
+ if (!src) return null
425
+ items.push(src.item)
426
+ if (src.list === parent && src.index < index) index-- // the board left a slot before the new one
427
+ }
428
+ const list = listIn(next, parent)!
429
+ list.splice(Math.min(index, list.length), 0, { kind: 'folder', name, items, ...(title ? { title } : {}) })
304
430
  return next
305
431
  }
306
432
 
@@ -325,19 +451,24 @@ export function slugFor(title: string, taken: string[], fallback = 'folder'): st
325
451
  for (let i = 2; ; i++) { const c = `${base.slice(0, NAME_MAX - 1 - String(i).length)}-${i}`; if (!taken.includes(c)) return c }
326
452
  }
327
453
 
328
- /** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
454
+ /** Folders organise, never own: deleting one moves what it holds up one level, into its
455
+ * place, in order - a top-level folder's boards and sub-folders to the root, a
456
+ * sub-folder's boards into its parent. */
329
457
  export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
330
458
  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 })))
459
+ const src = takeItem(next, 'folder', name)
460
+ if (!src) return null
461
+ listIn(next, src.list)!.splice(src.index, 0, ...(src.item as Folder).items)
335
462
  return next
336
463
  }
337
464
 
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))
465
+ /** Where "Move to new folder" puts the folder: in the board's own slot, at its own level - the
466
+ * root, or a sub-folder inside the top-level folder the board sits in; a board in a
467
+ * sub-folder gets the new folder right after that sub-folder (that level holds no folders). */
468
+ export function newFolderSlot(tree: TreeItem[], board: string): { parent: string | null; index: number } {
469
+ const home = folderOf(tree, board)
470
+ if (home === null) return { parent: null, index: Math.max(0, rootIndex(tree, 'board', board)) }
471
+ const up = parentOf(tree, home)
472
+ if (up === null) return { parent: home, index: Math.max(0, indexIn(tree, home, 'board', board)) }
473
+ return { parent: up, index: indexIn(tree, up, 'folder', home) + 1 }
343
474
  }
@@ -197,9 +197,9 @@ edges; a variant-group name is one indivisible atom). **The default composition
197
197
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
198
198
  band only when you can say why the eye should move down, and then with generous
199
199
  vertical space. Without a recipe the shell stacks every scene as its own row - so
200
- every curated board carries one. Boards can sit in **folders** (one level): put
201
- `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
202
- and ranks them - the human creates, renames, and drags folders in the sidebar too, so
200
+ every curated board carries one. Boards can sit in **folders**, two levels deep: put
201
+ `"folder": "<name>"` on a board file (the folder it sits in directly); `design/boards/_folders.json`
202
+ names empty folders, ranks them, and nests a sub-folder with `"parent"` - the human creates, renames, and drags folders in the sidebar too, so
203
203
  run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
204
204
  creating a board, organising boards, or publishing anything, read instructions/boards.md
205
205
  (the layout grammar, file format, folders and their moves, publishing rules).
@@ -197,9 +197,9 @@ edges; a variant-group name is one indivisible atom). **The default composition
197
197
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
198
198
  band only when you can say why the eye should move down, and then with generous
199
199
  vertical space. Without a recipe the shell stacks every scene as its own row - so
200
- every curated board carries one. Boards can sit in **folders** (one level): put
201
- `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
202
- and ranks them - the human creates, renames, and drags folders in the sidebar too, so
200
+ every curated board carries one. Boards can sit in **folders**, two levels deep: put
201
+ `"folder": "<name>"` on a board file (the folder it sits in directly); `design/boards/_folders.json`
202
+ names empty folders, ranks them, and nests a sub-folder with `"parent"` - the human creates, renames, and drags folders in the sidebar too, so
203
203
  run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
204
204
  creating a board, organising boards, or publishing anything, read instructions/boards.md
205
205
  (the layout grammar, file format, folders and their moves, publishing rules).