@marver-design/marver 0.20.0 → 0.22.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 (45) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +17 -4
  3. package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BTGNPVMx.mjs +187 -0
  6. package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
  7. package/dist/cli.mjs +67 -8
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-DUAENlJ6.mjs +600 -0
  10. package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
  11. package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
  12. package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
  15. package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
  16. package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
  17. package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
  18. package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
  19. package/docs/boards-and-folders.md +161 -0
  20. package/docs/context.md +117 -0
  21. package/docs/sharing.md +12 -2
  22. package/package.json +1 -1
  23. package/src/client/shell/BoardList.tsx +143 -54
  24. package/src/client/shell/ContextMenu.tsx +16 -5
  25. package/src/client/shell/StatusPicker.tsx +91 -0
  26. package/src/client/shell/board-icons.tsx +75 -0
  27. package/src/client/shell/store.ts +93 -14
  28. package/src/client/shell/styles.css +41 -9
  29. package/src/shared/board-tree.ts +291 -143
  30. package/src/shared/board-types.ts +103 -0
  31. package/src/shared/context.ts +265 -0
  32. package/src/shared/status.ts +157 -0
  33. package/templates/AGENTS-embedded.md +4 -3
  34. package/templates/AGENTS-studio.md +4 -3
  35. package/templates/context/INDEX.md +50 -0
  36. package/templates/context/map.json +6 -0
  37. package/templates/context/shipped-knowledge.md +19 -0
  38. package/templates/context/shipped.md +28 -0
  39. package/templates/instructions/boards.md +78 -22
  40. package/templates/instructions/context.md +114 -0
  41. package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
  42. package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
  43. package/templates/playbooks/reorganize-context/eval.md +93 -0
  44. package/dist/boards-BmxcT3Lc.mjs +0 -290
  45. package/dist/boards-PuVzw5Wp.mjs +0 -62
@@ -1,11 +1,15 @@
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
 
11
+ import { readType } from './board-types.ts'
12
+
9
13
  /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
10
14
  export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
11
15
  export const NAME_MAX = 64
@@ -17,11 +21,12 @@ export const FOLDERS_FILE = '_folders.json'
17
21
  * off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
18
22
  export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
19
23
 
20
- export type Folder = { kind: 'folder'; name: string; boards: string[]; title?: string; description?: string }
24
+ /** A folder and what it holds, in order: boards, and - in a top-level folder only - sub-folders. */
25
+ export type Folder = { kind: 'folder'; name: string; items: TreeItem[]; title?: string; description?: string; type?: string }
21
26
  export type TreeItem = { kind: 'board'; name: string } | Folder
22
27
 
23
28
  export interface BoardRow { name: string; order?: number; folder?: string; title?: string }
24
- export interface FolderRow { name: string; order?: number; title?: string; description?: string }
29
+ export interface FolderRow { name: string; order?: number; parent?: string; title?: string; description?: string; type?: string }
25
30
 
26
31
  /** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
27
32
  export const DESCRIPTION_MAX = 300
@@ -47,16 +52,32 @@ export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|
47
52
  export const labelOf = (name: string, title?: string): string => title ?? humanize(name)
48
53
 
49
54
  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 } : {}) })
55
+ /** A folder's title, description and type, present only when set. */
56
+ const folderExtras = (it: { title?: string; description?: string; type?: string }) => ({
57
+ ...(it.title ? { title: it.title } : {}),
58
+ ...(it.description ? { description: it.description } : {}),
59
+ ...(readType(it.type) ? { type: it.type } : {}),
60
+ })
61
+
62
+ /** The registry versions this code reads. Version 2 is written only when a folder has a
63
+ * `parent`, so a flat registry stays readable by every Marver; an older Marver refuses a
64
+ * version-2 file as malformed instead of rewriting it without its nesting. */
65
+ export const REGISTRY_VERSION_FLAT = 1
66
+ export const REGISTRY_VERSION_NESTED = 2
67
+ /** The tree-write protocol a shell speaks. A shell that predates nesting reads a nested
68
+ * registry flat (it ignores `parent`) and would post that flat tree back with a perfectly
69
+ * current hash - the hash proves freshness, not understanding - so the server refuses a write
70
+ * without this marker whenever the registry on disk nests. */
71
+ export const TREE_PROTOCOL = 2
52
72
 
53
73
  /** 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. */
74
+ * malformed registry is an ERROR the human must fix (silently reading it as empty, or
75
+ * flattening a broken nesting, would let the next drag overwrite their folders), while a
76
+ * missing file is simply no folders. */
56
77
  export function parseFolders(raw: unknown): FolderRow[] | string {
57
78
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
58
79
  const { version, folders } = raw as { version?: unknown; folders?: unknown }
59
- if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
80
+ if (version !== undefined && version !== REGISTRY_VERSION_FLAT && version !== REGISTRY_VERSION_NESTED) return `unsupported version ${String(version)}`
60
81
  if (!Array.isArray(folders)) return 'expected a "folders" array'
61
82
  const out: FolderRow[] = []
62
83
  const seen = new Set<string>()
@@ -66,66 +87,93 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
66
87
  if (seen.has(name)) return `folder "${name}" is listed twice`
67
88
  seen.add(name)
68
89
  const o = (f as { order?: unknown }).order
90
+ const p = (f as { parent?: unknown }).parent
91
+ if (p !== undefined && !isBoardName(p)) return `folder "${name}" has an invalid parent - a folder name`
69
92
  const t = readTitle((f as { title?: unknown }).title)
70
93
  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 } : {}) })
94
+ const ty = readType((f as { type?: unknown }).type)
95
+ out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(p !== undefined ? { parent: p } : {}), ...(t ? { title: t } : {}), ...(d ? { description: d } : {}), ...(ty ? { type: ty } : {}) })
96
+ }
97
+ const byName = new Map(out.map((f) => [f.name, f]))
98
+ for (const f of out) {
99
+ if (f.parent === undefined) continue
100
+ if (version !== REGISTRY_VERSION_NESTED) return `folder "${f.name}" names a parent - nested folders need "version": 2`
101
+ if (f.parent === f.name) return `folder "${f.name}" cannot be its own parent`
102
+ const p = byName.get(f.parent)
103
+ if (!p) return `folder "${f.name}" names an unknown parent "${f.parent}"`
104
+ if (p.parent !== undefined) return `folder "${f.name}" would sit three levels deep - folders nest one level only`
72
105
  }
73
106
  return out
74
107
  }
75
108
 
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. */
109
+ /** Sidebar order from the files. At every level the boards and folders there share one
110
+ * sequence, ranked by `order`, then kind (board before folder), then name; unranked sorts
111
+ * after ranked. The root holds root boards and top-level folders (registered without a
112
+ * parent, or implied by a board that names an unregistered folder); a top-level folder holds
113
+ * its boards and its sub-folders; a sub-folder holds its boards. A folder's title and
114
+ * description and type ride on its item (they live in the registry a tree write rewrites); a board's
115
+ * title stays with its row - it lives in the board's own file. */
81
116
  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)) }
117
+ const reg = new Map<string, FolderRow>()
118
+ for (const f of folders) if (isBoardName(f.name) && !reg.has(f.name)) reg.set(f.name, f)
119
+ // a parent the registry does not hold as a top-level folder leaves the child top-level -
120
+ // parseFolders refuses such files; this keeps buildTree total for any rows it is handed
121
+ const parentOf = (n: string): string | undefined => {
122
+ const p = reg.get(n)?.parent
123
+ return p !== undefined && p !== n && reg.has(p) && reg.get(p)!.parent === undefined ? p : undefined
124
+ }
85
125
  const members = new Map<string, BoardRow[]>()
126
+ const implied: string[] = []
86
127
  const rootBoards: BoardRow[] = []
87
128
  for (const b of boards) {
88
129
  if (!isBoardName(b.name) || b.name === 'all-scenes') continue
89
130
  const folder = isBoardName(b.folder) ? b.folder : undefined
90
131
  if (!folder) { rootBoards.push(b); continue }
91
- if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
132
+ if (!reg.has(folder) && !implied.includes(folder)) implied.push(folder) // implied by the board alone: always top-level
92
133
  const list = members.get(folder) ?? []
93
134
  list.push(b)
94
135
  members.set(folder, list)
95
136
  }
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)
137
+ type Ranked = { item: TreeItem; order: number | undefined }
138
+ const sorted = (xs: Ranked[]): TreeItem[] =>
139
+ 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)
140
+ const boardsHere = (name: string | null): Ranked[] =>
141
+ (name === null ? rootBoards : members.get(name) ?? []).map((b) => ({ item: { kind: 'board', name: b.name }, order: b.order }))
142
+ const folder = (name: string, kids: Ranked[]): Ranked =>
143
+ ({ item: { kind: 'folder', name, items: sorted(kids), ...folderExtras(reg.get(name) ?? {}) }, order: reg.get(name)?.order })
144
+ const subsOf = (top: string) => [...reg.keys()].filter((n) => parentOf(n) === top)
145
+ const tops = [...[...reg.keys()].filter((n) => !parentOf(n)), ...implied]
146
+ return sorted([
147
+ ...boardsHere(null),
148
+ ...tops.map((t) => folder(t, [...boardsHere(t), ...subsOf(t).map((s) => folder(s, boardsHere(s)))])),
149
+ ])
107
150
  }
108
151
 
109
- /** Every board in reading order - the order the switchers and the landing pick use. */
152
+ /** Every board in reading order, depth-first - the order the switchers and the landing pick use. */
110
153
  export function flatten(tree: TreeItem[]): string[] {
111
154
  const out: string[] = []
112
- for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
155
+ const walk = (items: TreeItem[]) => { for (const it of items) { if (it.kind === 'board') out.push(it.name); else walk(it.items) } }
156
+ walk(tree)
113
157
  return out
114
158
  }
115
159
 
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 }
160
+ /** The wire shape of a tree write (`POST boards/reorder`): plain strings for boards,
161
+ * `{ folder, items }` for folders, `items` holding boards and - one level down - folders.
162
+ * What the sidebar posts and the server validates. A folder's title, description and type ride
163
+ * with it (they live in the registry the write rewrites); a board's title lives in its own
164
+ * file and never rides the tree. A one-level `{ folder, boards }` item (an older shell) is
165
+ * still read. */
166
+ export type WireItem = string | { folder: string; items: WireItem[]; title?: string; description?: string; type?: string }
167
+ type WireIn = string | { folder: string; items?: WireIn[]; boards?: string[]; title?: string; description?: string; type?: string }
121
168
  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) }))
169
+ tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, items: toWire(it.items), ...folderExtras(it) }))
170
+ export const wireKids = (w: Exclude<WireIn, string>): WireIn[] => w.items ?? w.boards ?? []
171
+ export const fromWire = (wire: WireIn[]): TreeItem[] =>
172
+ wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, items: fromWire(wireKids(w)), ...folderExtras(w) }))
125
173
 
126
174
  /** 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. */
175
+ * every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, folders
176
+ * two levels deep at most, bounded. */
129
177
  export const TREE_MAX_BOARDS = 200
130
178
  export const TREE_MAX_FOLDERS = 50
131
179
  export function validateWire(wire: unknown): string | null {
@@ -137,18 +185,28 @@ export function validateWire(wire: unknown): string | null {
137
185
  boards.add(n)
138
186
  return null
139
187
  }
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 }
188
+ const walk = (list: unknown[], depth: number): string | null => {
189
+ for (const w of list) {
190
+ if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
191
+ if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
192
+ if (depth >= 2) return 'folders nest one level only'
193
+ const { folder, items, boards: legacy, title, description, type } = w as { folder?: unknown; items?: unknown; boards?: unknown; title?: unknown; description?: unknown; type?: unknown }
194
+ if (!isBoardName(folder)) return 'invalid folder name in tree'
195
+ if (title !== undefined && (typeof title !== 'string' || Array.from(title).length > TITLE_MAX)) return 'invalid folder title'
196
+ if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
197
+ if (type !== undefined && !readType(type)) return 'invalid folder type'
198
+ if (folders.has(folder)) return `folder "${folder}" appears twice`
199
+ folders.add(folder)
200
+ const kids = items ?? legacy
201
+ if (!Array.isArray(kids)) return 'invalid folder in tree'
202
+ if (items === undefined && kids.some((k) => typeof k !== 'string')) return 'invalid folder in tree' // a one-level item holds boards only
203
+ const e = walk(kids, depth + 1)
204
+ if (e) return e
205
+ }
206
+ return null
151
207
  }
208
+ const e = walk(wire, 0)
209
+ if (e) return e
152
210
  if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
153
211
  return null
154
212
  }
@@ -160,26 +218,68 @@ export function slugify(raw: string): string {
160
218
  return isBoardName(s) ? s : ''
161
219
  }
162
220
 
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)
221
+ // ---- reading the tree ----
171
222
 
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 } }
223
+ /** A deep copy: mutations work on it and the caller's tree stays as it was (a folder's
224
+ * title, description and type ride along). */
225
+ export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, items: cloneTree(it.items) }))
226
+ /** Every folder with the folder it sits in (null = the root), top-level folders first in
227
+ * reading order, each followed by its sub-folders. */
228
+ export function folderEntries(t: TreeItem[]): { folder: Folder; parent: string | null }[] {
229
+ const out: { folder: Folder; parent: string | null }[] = []
176
230
  for (const it of t) {
177
231
  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 } }
232
+ out.push({ folder: it, parent: null })
233
+ for (const k of it.items) if (k.kind === 'folder') out.push({ folder: k, parent: it.name })
180
234
  }
235
+ return out
236
+ }
237
+ /** A folder anywhere in the tree. */
238
+ export const folderIn = (t: TreeItem[], name: string): Folder | undefined => folderEntries(t).find((e) => e.folder.name === name)?.folder
239
+ /** The folder a folder sits in - null for a top-level folder (or one that is not there). */
240
+ export const parentOf = (t: TreeItem[], name: string): string | null => folderEntries(t).find((e) => e.folder.name === name)?.parent ?? null
241
+ /** Every board's folder, in one pass - for callers that ask for many boards (folderOf walks the tree). */
242
+ export function folderMap(t: TreeItem[]): Map<string, string> {
243
+ const out = new Map<string, string>()
244
+ for (const { folder } of folderEntries(t)) for (const k of folder.items) if (k.kind === 'board') out.set(k.name, folder.name)
245
+ return out
246
+ }
247
+ /** The folder a board sits in directly, at either level - null at the root. */
248
+ export function folderOf(t: TreeItem[], board: string): string | null {
249
+ for (const { folder } of folderEntries(t)) if (folder.items.some((k) => k.kind === 'board' && k.name === board)) return folder.name
181
250
  return null
182
251
  }
252
+ export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
253
+ export const foldersIn = (t: TreeItem[]): string[] => folderEntries(t).map((e) => e.folder.name)
254
+ /** Does this folder hold folders? Such a folder can only ever sit at the root. */
255
+ export const hasSubfolders = (f: Folder): boolean => f.items.some((k) => k.kind === 'folder')
256
+ /** The list a slot lives in: the root (null) or a folder's items. */
257
+ export const listIn = (t: TreeItem[], list: string | null): TreeItem[] | undefined => (list === null ? t : folderIn(t, list)?.items)
258
+ /** Where an item sits in a list, or -1. */
259
+ export const indexIn = (t: TreeItem[], list: string | null, kind: TreeItem['kind'], name: string): number =>
260
+ listIn(t, list)?.findIndex((it) => it.kind === kind && it.name === name) ?? -1
261
+ export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => indexIn(t, null, kind, name)
262
+ /** How deep a list sits: the root 0, a top-level folder's items 1, a sub-folder's items 2. */
263
+ export const depthOf = (t: TreeItem[], list: string | null): number => (list === null ? 0 : parentOf(t, list) === null ? 1 : 2)
264
+
265
+ /** Remove an item wherever it sits; returns the list it left (root = null), its index there
266
+ * and the item itself. */
267
+ export function takeItem(t: TreeItem[], kind: TreeItem['kind'], name: string): { list: string | null; index: number; item: TreeItem } | null {
268
+ const lists: (string | null)[] = [null, ...foldersIn(t)]
269
+ for (const list of lists) {
270
+ const items = listIn(t, list)!
271
+ const i = items.findIndex((it) => it.kind === kind && it.name === name)
272
+ if (i >= 0) { const [item] = items.splice(i, 1); return { list, index: i, item: item! } }
273
+ }
274
+ return null
275
+ }
276
+ /** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
277
+ export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
278
+ const r = takeItem(t, 'board', board)
279
+ return r ? { list: r.list, index: r.index } : null
280
+ }
281
+
282
+ // ---- drag and drop ----
183
283
 
184
284
  /** What is being dragged, and where it may land: a slot in a list (root = null) or the
185
285
  * inside of a folder (appended at its end). */
@@ -187,120 +287,163 @@ export type Drag = { kind: TreeItem['kind']; name: string }
187
287
  export type Drop = { list: string | null; index: number } | { into: string }
188
288
 
189
289
  /** 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
290
+ * the pinned `all-scenes` row included (it is the root's end). `parent` is the folder the
291
+ * row sits in (null = the root) and `depth` that list's depth (0 root, 1 a top-level
292
+ * folder's items, 2 a sub-folder's). `open` is a folder's disclosure; `left` is the row's
293
+ * left edge, from which the indent of each level is measured. */
294
+ export interface Row { kind: TreeItem['kind']; name: string; parent: string | null; depth: number; open?: boolean; top: number; bottom: number; left: number }
295
+ /** Px each level of nesting indents its rows; the seams inside draw from there too. One step is
296
+ * a row's lead (1px icon margin + 14px icon + 1px margin + 7px gap) less the icon's own left
297
+ * margin, so a nested row's icon starts exactly where its folder's name does (styles.css,
298
+ * `.in-folder` / `.in-sub`). */
299
+ export const INDENT = 22
300
+
301
+ /** How deep the dragged item may land: a board anywhere (2), a folder without sub-folders
302
+ * inside a top-level folder (1), a folder holding sub-folders at the root only (0). */
303
+ function landingDepth(t: TreeItem[], d: Drag): number {
304
+ if (d.kind === 'board') return 2
305
+ const f = folderIn(t, d.name)
306
+ return f && !hasSubfolders(f) ? 1 : 0
307
+ }
195
308
 
196
309
  /** The drop target for a pointer at (x, y) over the rendered rows - never null inside the
197
310
  * 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. */
311
+ * showed. INTO: the middle band of a folder header (a closed one: its lower band too), when
312
+ * what is dragged may sit inside it. Otherwise the nearest gap by row midlines. A gap inside
313
+ * a list belongs to it. A gap where lists END - after the last row of a folder, or under an
314
+ * open empty folder - is shared by every level that ends there, down to the level of the row
315
+ * below; the row under the pointer decides: still over the row above, the deepest level whose
316
+ * indent the pointer reaches; over the row below (or past it), that row's own level. A folder
317
+ * never lands inside itself; one that holds sub-folders moves between root items only, each
318
+ * root item one block. */
206
319
  export function resolveDrop(t: TreeItem[], d: Drag, rows: Row[], x: number, y: number): Drop | null {
207
320
  const mid = (r: { top: number; bottom: number }) => (r.top + r.bottom) / 2
208
- if (d.kind === 'folder') {
321
+ const deepest = landingDepth(t, d)
322
+ if (deepest === 0) {
209
323
  const blocks: { top: number; bottom: number }[] = []
210
324
  for (const r of rows) {
211
- if (r.parent && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
325
+ if (r.depth > 0 && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
212
326
  blocks.push({ top: r.top, bottom: r.bottom })
213
327
  }
214
328
  let g = 0
215
329
  for (const b of blocks) if (y >= mid(b)) g++
216
330
  return { list: null, index: Math.min(g, t.length) }
217
331
  }
218
- for (const r of rows) {
219
- if (r.kind !== 'folder' || y < r.top || y >= r.bottom) continue
332
+ // what a dragged folder holds is never a place to land; its own header stays in the
333
+ // geometry, so a wobble over it resolves to its own slot - a no-op, never an eviction
334
+ const rs = d.kind === 'folder' ? rows.filter((r) => r.parent !== d.name && !(r.parent && parentOf(t, r.parent) === d.name)) : rows
335
+ for (const r of rs) {
336
+ if (r.kind !== 'folder' || (d.kind === 'folder' && r.name === d.name) || y < r.top || y >= r.bottom || r.depth + 1 > deepest) continue
220
337
  const f = (y - r.top) / (r.bottom - r.top)
221
338
  if (f >= 0.25 && (f < 0.75 || !r.open)) return { into: r.name }
222
339
  }
223
340
  let g = 0
224
- for (const r of rows) if (y >= mid(r)) g++
225
- const above = rows[g - 1], below = rows[g]
341
+ for (const r of rs) if (y >= mid(r)) g++
342
+ const above = rs[g - 1], below = rs[g]
226
343
  if (!above) return { list: null, index: 0 }
227
344
  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 })
345
+ type Slot = { list: string | null; index: number; depth: number }
346
+ // the deepest slot the gap can mean: inside an open folder, under its header - or right after the row above, in its own list
347
+ let first: Slot
348
+ if (above.kind === 'folder' && above.open && !(d.kind === 'folder' && above.name === d.name)) first = { list: above.name, index: 0, depth: above.depth + 1 }
349
+ else {
350
+ const i = indexIn(t, above.parent, above.kind, above.name)
351
+ if (i < 0) return null
352
+ first = { list: above.parent, index: i + 1, depth: above.depth }
353
+ }
354
+ // then, level by level up to the root: right after the folder that holds the slot before
355
+ const chain: Slot[] = [first]
356
+ for (let s = first; s.list !== null;) {
357
+ const up = parentOf(t, s.list)
358
+ const i = indexIn(t, up, 'folder', s.list)
359
+ if (i < 0) return null
360
+ s = { list: up, index: i + 1, depth: s.depth - 1 }
361
+ chain.push(s)
236
362
  }
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 })
363
+ const floor = below ? below.depth : 0
364
+ const slots = chain.filter((s) => s.depth >= floor && s.depth <= deepest)
365
+ const strip = (s: Slot): Drop => ({ list: s.list, index: s.index })
366
+ if (!slots.length) { const s = chain.find((c) => c.depth <= deepest); return s ? strip(s) : null }
367
+ if (slots.length === 1) return strip(slots[0]!)
368
+ if (y < above.bottom) for (const s of slots) if (x >= above.left + s.depth * INDENT) return strip(s)
369
+ return strip(slots[slots.length - 1]!)
242
370
  }
243
371
 
244
372
  /** 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. */
373
+ * the folder an item already sits in means "to its end" - a no-op only when it is last. */
246
374
  export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
247
375
  if ('into' in target) {
248
- if (d.kind !== 'board') return true
249
376
  const f = folderIn(t, target.into)
250
- return !!f && f.boards[f.boards.length - 1] === d.name
377
+ const last = f?.items[f.items.length - 1]
378
+ return !!last && last.kind === d.kind && last.name === d.name
251
379
  }
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
380
+ const from = indexIn(t, target.list, d.kind, d.name)
256
381
  return from >= 0 && (target.index === from || target.index === from + 1)
257
382
  }
258
383
 
259
- /** The tree after a drop, or null when the drop is impossible on this tree. */
384
+ /** The tree after a drop, or null when the drop is impossible on this tree: a folder into
385
+ * itself, into a sub-folder, or - when it holds sub-folders - into any folder. */
260
386
  export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
261
387
  const next = cloneTree(tree)
388
+ const dest = 'into' in target ? target.into : target.list
262
389
  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)
390
+ const f = folderIn(next, d.name)
274
391
  if (!f) return null
275
- f.boards.push(d.name)
276
- return next
392
+ if (dest !== null && (dest === d.name || hasSubfolders(f) || !folderIn(next, dest) || parentOf(next, dest) !== null)) return null
277
393
  }
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)
394
+ const src = takeItem(next, d.kind, d.name)
395
+ if (!src) return null
396
+ const list = listIn(next, dest)
397
+ if (!list) return null
398
+ 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
399
+ list.splice(Math.min(to, list.length), 0, src.item)
283
400
  return next
284
401
  }
285
402
 
286
- /** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
403
+ // ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
404
+
405
+ /** Move a board into a folder at either level (at its end), or to the root at `atRoot`
406
+ * (default: the end). */
287
407
  export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
288
408
  const next = cloneTree(tree)
289
409
  if (!takeBoard(next, board)) return null
290
410
  if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
291
411
  const f = folderIn(next, folder)
292
412
  if (!f) return null
293
- f.boards.push(board)
413
+ f.items.push({ kind: 'board', name: board })
414
+ return next
415
+ }
416
+
417
+ /** Move a folder to the root at `atRoot` (default: the end) - "Move to top level" for a sub-folder. */
418
+ export function moveFolderToRoot(tree: TreeItem[], name: string, atRoot?: number): TreeItem[] | null {
419
+ const next = cloneTree(tree)
420
+ const src = takeItem(next, 'folder', name)
421
+ if (!src) return null
422
+ // an explicit slot was measured before the folder left the root; the default end was not
423
+ const at = atRoot === undefined ? next.length : src.list === null && atRoot > src.index ? atRoot - 1 : atRoot
424
+ next.splice(Math.min(at, next.length), 0, src.item)
294
425
  return next
295
426
  }
296
427
 
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 {
428
+ /** Can this list hold a folder? The root and a top-level folder can; a sub-folder, or a
429
+ * folder that is not there, cannot. */
430
+ export const holdsFolders = (t: TreeItem[], parent: string | null): boolean => parent === null || (!!folderIn(t, parent) && parentOf(t, parent) === null)
431
+
432
+ /** A new folder at `index` in `parent`'s items (null = the root), holding `board` (pulled
433
+ * from wherever it sat) when given. Only the root and top-level folders hold folders. */
434
+ export function createFolder(tree: TreeItem[], name: string, index: number, board?: string, title?: string, parent: string | null = null): TreeItem[] | null {
299
435
  if (foldersIn(tree).includes(name)) return null
300
436
  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 } : {}) })
437
+ if (!holdsFolders(next, parent)) return null
438
+ const items: TreeItem[] = []
439
+ if (board) {
440
+ const src = takeItem(next, 'board', board)
441
+ if (!src) return null
442
+ items.push(src.item)
443
+ if (src.list === parent && src.index < index) index-- // the board left a slot before the new one
444
+ }
445
+ const list = listIn(next, parent)!
446
+ list.splice(Math.min(index, list.length), 0, { kind: 'folder', name, items, ...(title ? { title } : {}) })
304
447
  return next
305
448
  }
306
449
 
@@ -325,19 +468,24 @@ export function slugFor(title: string, taken: string[], fallback = 'folder'): st
325
468
  for (let i = 2; ; i++) { const c = `${base.slice(0, NAME_MAX - 1 - String(i).length)}-${i}`; if (!taken.includes(c)) return c }
326
469
  }
327
470
 
328
- /** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
471
+ /** Folders organise, never own: deleting one moves what it holds up one level, into its
472
+ * place, in order - a top-level folder's boards and sub-folders to the root, a
473
+ * sub-folder's boards into its parent. */
329
474
  export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
330
475
  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 })))
476
+ const src = takeItem(next, 'folder', name)
477
+ if (!src) return null
478
+ listIn(next, src.list)!.splice(src.index, 0, ...(src.item as Folder).items)
335
479
  return next
336
480
  }
337
481
 
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))
482
+ /** Where "Move to new folder" puts the folder: in the board's own slot, at its own level - the
483
+ * root, or a sub-folder inside the top-level folder the board sits in; a board in a
484
+ * sub-folder gets the new folder right after that sub-folder (that level holds no folders). */
485
+ export function newFolderSlot(tree: TreeItem[], board: string): { parent: string | null; index: number } {
486
+ const home = folderOf(tree, board)
487
+ if (home === null) return { parent: null, index: Math.max(0, rootIndex(tree, 'board', board)) }
488
+ const up = parentOf(tree, home)
489
+ if (up === null) return { parent: home, index: Math.max(0, indexIn(tree, home, 'board', board)) }
490
+ return { parent: up, index: indexIn(tree, up, 'folder', home) + 1 }
343
491
  }