@marver-design/marver 0.16.0 → 0.17.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.
- package/CHANGELOG.md +51 -0
- package/README.md +4 -4
- package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
- package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
- package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
- package/dist/cli.mjs +27 -16
- package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
- package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
- package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
- package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
- package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
- package/dist/shot-BzQ0PXKH.mjs +86 -0
- package/dist/shot-DlmTO8AF.mjs +926 -0
- package/docs/live-jam.md +20 -8
- package/docs/publish.md +2 -2
- package/package.json +1 -1
- package/src/client/shell/App.tsx +42 -14
- package/src/client/shell/BoardList.tsx +88 -58
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +76 -29
- package/src/client/shell/styles.css +5 -1
- package/src/shared/board-tree.ts +104 -46
- package/templates/AGENTS-embedded.md +13 -3
- package/templates/AGENTS-studio.md +13 -3
- package/templates/instructions/boards.md +27 -9
- package/templates/instructions/discover.md +13 -1
- package/templates/instructions/jam.md +15 -3
- package/templates/instructions/shape.md +2 -1
- package/dist/shot-By1AItpD.mjs +0 -30
- package/dist/shot-CwmHO5T4.mjs +0 -528
|
@@ -14,6 +14,8 @@ const DATA: {
|
|
|
14
14
|
manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
|
|
15
15
|
/** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */
|
|
16
16
|
tree?: TreeItem[]
|
|
17
|
+
/** slug → title of the published boards (0.16.1 bundles); folder titles ride on `tree` */
|
|
18
|
+
titles?: Record<string, string>
|
|
17
19
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
18
20
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
19
21
|
} | null = shData
|
|
@@ -44,7 +46,7 @@ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean }
|
|
47
|
-
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
49
|
+
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string }[] }
|
|
48
50
|
export interface Node {
|
|
49
51
|
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
50
52
|
/** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
|
|
@@ -125,23 +127,23 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
125
127
|
export { cap, humanize } from './labels.ts'
|
|
126
128
|
import { cap, humanize } from './labels.ts'
|
|
127
129
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
128
|
-
import { buildTree, flatten, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
130
|
+
import { buildTree, flatten, labelOf, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
129
131
|
|
|
130
132
|
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
131
133
|
* the folder registry (null = there was no file). */
|
|
132
134
|
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
133
|
-
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase }
|
|
135
|
+
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
|
|
134
136
|
|
|
135
137
|
/** The sidebar tree: root boards and folders in rank order, each folder's boards inside
|
|
136
138
|
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
137
139
|
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
138
140
|
* (the server's 422 message) - callers keep their last known tree. */
|
|
139
141
|
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
140
|
-
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null } }
|
|
142
|
+
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null }, titles: DATA.titles ?? {} }
|
|
141
143
|
const [boards, reg] = await Promise.all([
|
|
142
|
-
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string }[]>,
|
|
144
|
+
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
|
|
143
145
|
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
144
|
-
const j = await r.json() as { folders?: { name: string; order?: number }[]; sha256?: string | null; error?: string }
|
|
146
|
+
const j = await r.json() as { folders?: { name: string; order?: number; title?: string }[]; sha256?: string | null; error?: string }
|
|
145
147
|
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
146
148
|
return j
|
|
147
149
|
}),
|
|
@@ -149,20 +151,32 @@ export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
|
149
151
|
return {
|
|
150
152
|
tree: buildTree(boards, reg.folders ?? []),
|
|
151
153
|
base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
|
|
154
|
+
titles: Object.fromEntries(boards.flatMap((b) => (b.title ? [[b.name, b.title]] : []))),
|
|
152
155
|
}
|
|
153
156
|
}
|
|
157
|
+
/** A tree read's board titles become the labels' - called by the reader once it knows the
|
|
158
|
+
* read is its latest (an older response must not overwrite a newer one's labels). Folder
|
|
159
|
+
* titles ride on the tree's folder items: a board and a folder may share a slug, never one map. */
|
|
160
|
+
export function rememberTitles(titles: Record<string, string>) {
|
|
161
|
+
if (JSON.stringify(useStore.getState().boardTitles) !== JSON.stringify(titles)) useStore.setState({ boardTitles: titles })
|
|
162
|
+
}
|
|
154
163
|
|
|
155
164
|
/** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
|
|
156
165
|
* the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
157
166
|
* Throws on transport failure - callers keep their last known list. */
|
|
158
167
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
159
168
|
if (DATA) return DATA.names
|
|
160
|
-
|
|
169
|
+
const snap = await fetchBoardTree()
|
|
170
|
+
rememberTitles(snap.titles)
|
|
171
|
+
return [...flatten(snap.tree), 'all-scenes']
|
|
161
172
|
}
|
|
162
173
|
/** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
|
|
163
174
|
export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
|
|
164
|
-
/** Display name for a board:
|
|
165
|
-
|
|
175
|
+
/** Display name for a board: its title when one is set (subscribe to `boardTitles` to
|
|
176
|
+
* re-render on change), else the Title-Cased slug ('all-scenes' reads "All Scenes"). */
|
|
177
|
+
export const boardLabel = (n: string) => labelOf(n, useStore.getState().boardTitles[n])
|
|
178
|
+
/** Display name for a scene: the title in its brief's front matter, else the Title-Cased directory. */
|
|
179
|
+
export const sceneLabel = (n: string) => labelOf(n, useStore.getState().manifest?.scenes.find((s) => s.name === n)?.title)
|
|
166
180
|
|
|
167
181
|
/** The double-submit CSRF token the owner gate wants echoed (same read as comments-store). */
|
|
168
182
|
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
|
|
@@ -208,6 +222,8 @@ const initialViewTheme = () => {
|
|
|
208
222
|
return CONFIG.themes[0] ?? 'light'
|
|
209
223
|
}
|
|
210
224
|
const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
|
|
225
|
+
let scenesRev = 0 // bumps per sh:scenes, so a load that straddled one keeps the live labels
|
|
226
|
+
let liveScenes: Manifest['scenes'] | null = null // the last sh:scenes payload - applied late when it beat the first manifest
|
|
211
227
|
|
|
212
228
|
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design:
|
|
213
229
|
* auto sizes are never serialized - a reload remeasures. */
|
|
@@ -304,7 +320,16 @@ interface State {
|
|
|
304
320
|
setDeviceView(name: string | null): void
|
|
305
321
|
resizeSelected(name: string | null): void
|
|
306
322
|
switchBoard(name: string): Promise<void>
|
|
307
|
-
|
|
323
|
+
/** what the sidebar labels boards by: slug → title, off the last tree read */
|
|
324
|
+
boardTitles: Record<string, string>
|
|
325
|
+
/** retitle a board - what humans see ('' = back to the Title-Cased slug). The file never
|
|
326
|
+
* moves: its name is the board's identity (agents, publish.json, URLs, comment threads).
|
|
327
|
+
* `baseHash` = the file as last seen; a 409 (`stale`) means someone wrote it since. */
|
|
328
|
+
renameBoard(name: string, title: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
|
|
329
|
+
/** a scene's title, into its brief's front matter (the directory never moves) */
|
|
330
|
+
renameScene(scene: string, title: string): Promise<{ ok: boolean; error?: string }>
|
|
331
|
+
/** `sh:scenes`: the scenes changed (a brief's title or description) with the frames intact */
|
|
332
|
+
setScenes(scenes: Manifest['scenes']): void
|
|
308
333
|
arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
|
|
309
334
|
pulsePath(): void
|
|
310
335
|
copyFrameImage(scale: 2 | 4): void
|
|
@@ -601,13 +626,13 @@ export const useStore = create<State>((set, get) => {
|
|
|
601
626
|
return {
|
|
602
627
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
603
628
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
604
|
-
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
|
|
629
|
+
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {},
|
|
605
630
|
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
|
|
606
631
|
|
|
607
632
|
async boot() {
|
|
608
633
|
const seq = ++loadSeq
|
|
609
634
|
const boardName = get().board
|
|
610
|
-
const revAtStart = editRev
|
|
635
|
+
const revAtStart = editRev, scenesAtStart = scenesRev
|
|
611
636
|
const next = await loadBoardState(boardName)
|
|
612
637
|
if (seq !== loadSeq) return false // a newer load superseded this one
|
|
613
638
|
if (!next) { get().toast(`board "${boardName}" failed to load`); return false }
|
|
@@ -617,11 +642,13 @@ export const useStore = create<State>((set, get) => {
|
|
|
617
642
|
set(next)
|
|
618
643
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
619
644
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
645
|
+
else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) // an sh:scenes that landed mid-fetch outranks the file we read
|
|
620
646
|
return true
|
|
621
647
|
},
|
|
622
648
|
|
|
623
649
|
async switchBoard(name) {
|
|
624
650
|
const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
|
|
651
|
+
const scenesAtStart = scenesRev
|
|
625
652
|
if (name === get().board) return
|
|
626
653
|
// a resize held across a switch: settle THIS board (retidy if it has a recipe)
|
|
627
654
|
// before the flush, so the torn mid-gesture state is never what reaches disk
|
|
@@ -649,41 +676,61 @@ export const useStore = create<State>((set, get) => {
|
|
|
649
676
|
set({ board: name, interact: null, ...next })
|
|
650
677
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
651
678
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
679
|
+
else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } })
|
|
652
680
|
},
|
|
653
681
|
|
|
654
|
-
renameBoard(
|
|
682
|
+
renameBoard(name, title, baseHash) {
|
|
655
683
|
return structural(async () => {
|
|
684
|
+
const from = name
|
|
656
685
|
const active = from === get().board
|
|
657
|
-
//
|
|
658
|
-
//
|
|
686
|
+
// Retitling the ACTIVE board rewrites its file under the autosave. Flush any pending
|
|
687
|
+
// write FIRST (switchBoard's pattern), so a PUT never races the rewrite.
|
|
659
688
|
if (active) {
|
|
660
689
|
let ok = true
|
|
661
690
|
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
662
691
|
if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
|
|
663
|
-
// hold autosave across the round-trip: an edit landing mid-
|
|
664
|
-
//
|
|
692
|
+
// hold autosave across the round-trip: an edit landing mid-write would PUT against a
|
|
693
|
+
// hash the rewrite is about to move
|
|
665
694
|
holdSaves()
|
|
666
695
|
}
|
|
667
|
-
// Release the hold and resume autosave. Re-read the LIVE board, not the captured `active`:
|
|
668
|
-
// a board switch may have completed while we awaited, so only the board that is STILL
|
|
669
|
-
// `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
|
|
670
696
|
try {
|
|
697
|
+
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
698
|
+
// the sidebar looked); every other board's is the sidebar's
|
|
699
|
+
const base = active && get().boardHash ? get().boardHash : baseHash
|
|
671
700
|
let res: Response
|
|
672
|
-
try { res = await postOwner('boards/rename', { from,
|
|
701
|
+
try { res = await postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) }) }
|
|
673
702
|
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
703
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
|
|
704
|
+
if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `rename failed (${res.status})` }
|
|
705
|
+
// the write rewrote the file: the active board's CAS token moves to the hash the server
|
|
706
|
+
// answered (else the next autosave 409s against our own write) - before releasing the hold
|
|
707
|
+
if (active && get().board === from && body?.sha256) set({ boardHash: body.sha256 })
|
|
708
|
+
const titles = { ...get().boardTitles } // the label changes now; the next tree read confirms it from the file
|
|
709
|
+
if (title) titles[from] = title; else delete titles[from]
|
|
710
|
+
set({ boardTitles: titles })
|
|
682
711
|
return { ok: true }
|
|
683
712
|
} finally { if (active) releaseSaves() } // whatever happened, autosave resumes
|
|
684
713
|
})
|
|
685
714
|
},
|
|
686
715
|
|
|
716
|
+
async renameScene(scene, title) {
|
|
717
|
+
let res: Response
|
|
718
|
+
try { res = await postOwner('scenes/rename', { scene, title }) }
|
|
719
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
720
|
+
if (!res.ok) { const e = await res.json().catch(() => ({} as { error?: string })); return { ok: false, error: e?.error ?? `rename failed (${res.status})` } }
|
|
721
|
+
// the label changes now; the watcher's sh:scenes confirms it from the file
|
|
722
|
+
const m = get().manifest
|
|
723
|
+
if (m) set({ manifest: { ...m, scenes: m.scenes.map((s) => (s.name === scene ? { ...s, ...(title ? { title } : {}), ...(title ? {} : { title: undefined }) } : s)) } })
|
|
724
|
+
return { ok: true }
|
|
725
|
+
},
|
|
726
|
+
setScenes(scenes) {
|
|
727
|
+
if (!Array.isArray(scenes)) return
|
|
728
|
+
scenesRev++
|
|
729
|
+
liveScenes = scenes
|
|
730
|
+
const m = get().manifest
|
|
731
|
+
if (m) set({ manifest: { ...m, scenes } }) // before the first manifest: kept for the boot's commit
|
|
732
|
+
},
|
|
733
|
+
|
|
687
734
|
// The whole sidebar tree in one write: order, membership, the folders themselves. The
|
|
688
735
|
// write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
|
|
689
736
|
// rename: flush the pending autosave first, hold autosave across the round-trip, then
|
|
@@ -526,8 +526,12 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
526
526
|
.sh-panel .it.folder.drop-into svg { color: #0088ff }
|
|
527
527
|
.sh-panel .it.board.in-folder { padding-left: 28px }
|
|
528
528
|
.sh-panel .it.board.draft { cursor: default; opacity: .6 }
|
|
529
|
-
/* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike
|
|
529
|
+
/* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike;
|
|
530
|
+
an open EMPTY folder draws that indented seam under its own header (drop-in) */
|
|
530
531
|
.sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
|
|
532
|
+
.sh-panel .it.folder.drop-in { position: relative }
|
|
533
|
+
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: 28px; right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
|
|
534
|
+
background: #0088ff; pointer-events: none }
|
|
531
535
|
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
532
536
|
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|
|
533
537
|
.sh-panel .sh-boards .hd span { flex: 1 }
|
package/src/shared/board-tree.ts
CHANGED
|
@@ -17,11 +17,11 @@ export const FOLDERS_FILE = '_folders.json'
|
|
|
17
17
|
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
18
18
|
export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
|
|
19
19
|
|
|
20
|
-
export type Folder = { kind: 'folder'; name: string; boards: string[]; description?: string }
|
|
20
|
+
export type Folder = { kind: 'folder'; name: string; boards: string[]; title?: string; description?: string }
|
|
21
21
|
export type TreeItem = { kind: 'board'; name: string } | Folder
|
|
22
22
|
|
|
23
|
-
export interface BoardRow { name: string; order?: number; folder?: string }
|
|
24
|
-
export interface FolderRow { name: string; order?: number; description?: string }
|
|
23
|
+
export interface BoardRow { name: string; order?: number; folder?: string; title?: string }
|
|
24
|
+
export interface FolderRow { name: string; order?: number; title?: string; description?: string }
|
|
25
25
|
|
|
26
26
|
/** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
|
|
27
27
|
export const DESCRIPTION_MAX = 300
|
|
@@ -31,7 +31,24 @@ export const readDescription = (v: unknown): string | undefined => {
|
|
|
31
31
|
return s || undefined
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** A title off a file - what humans see, free text: any casing, punctuation, emoji. Control
|
|
35
|
+
* characters dropped, whitespace collapsed, capped in code points (never half a surrogate
|
|
36
|
+
* pair). Absent when empty or not a string; the display then falls back to the slug. */
|
|
37
|
+
export const TITLE_MAX = 120
|
|
38
|
+
export const readTitle = (v: unknown): string | undefined => {
|
|
39
|
+
if (typeof v !== 'string') return undefined
|
|
40
|
+
// eslint-disable-next-line no-control-regex
|
|
41
|
+
const s = Array.from(v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().replace(/\s+/g, ' ')).slice(0, TITLE_MAX).join('').trim()
|
|
42
|
+
return s || undefined
|
|
43
|
+
}
|
|
44
|
+
/** Title Case off a slug - the display when no title is set ("old-stuff" → "Old Stuff"). */
|
|
45
|
+
export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|\s)\S/g, (c) => c.toUpperCase())
|
|
46
|
+
/** What a board, folder or scene is called on screen: its title, else its humanized slug. */
|
|
47
|
+
export const labelOf = (name: string, title?: string): string => title ?? humanize(name)
|
|
48
|
+
|
|
34
49
|
const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
|
|
50
|
+
/** A folder's title and description, present only when set. */
|
|
51
|
+
const folderExtras = (it: { title?: string; description?: string }) => ({ ...(it.title ? { title: it.title } : {}), ...(it.description ? { description: it.description } : {}) })
|
|
35
52
|
|
|
36
53
|
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
37
54
|
* malformed registry is an ERROR the human must fix (silently reading it as empty would
|
|
@@ -49,19 +66,22 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
|
|
|
49
66
|
if (seen.has(name)) return `folder "${name}" is listed twice`
|
|
50
67
|
seen.add(name)
|
|
51
68
|
const o = (f as { order?: unknown }).order
|
|
69
|
+
const t = readTitle((f as { title?: unknown }).title)
|
|
52
70
|
const d = readDescription((f as { description?: unknown }).description)
|
|
53
|
-
out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(d ? { description: d } : {}) })
|
|
71
|
+
out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(t ? { title: t } : {}), ...(d ? { description: d } : {}) })
|
|
54
72
|
}
|
|
55
73
|
return out
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/** Sidebar order from the files. Root: boards with no folder + every folder (registered
|
|
59
77
|
* or implied by a board), ranked by `order` then kind (board before folder) then name.
|
|
60
|
-
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked.
|
|
78
|
+
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked. A folder's
|
|
79
|
+
* title and description ride on its item (they live in the registry a tree write rewrites);
|
|
80
|
+
* a board's title stays with its row - it lives in the board's own file. */
|
|
61
81
|
export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
|
|
62
82
|
const folderOrder = new Map<string, number | undefined>()
|
|
63
|
-
const
|
|
64
|
-
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order);
|
|
83
|
+
const folderMeta = new Map<string, { title?: string; description?: string }>()
|
|
84
|
+
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order); folderMeta.set(f.name, folderExtras(f)) }
|
|
65
85
|
const members = new Map<string, BoardRow[]>()
|
|
66
86
|
const rootBoards: BoardRow[] = []
|
|
67
87
|
for (const b of boards) {
|
|
@@ -78,7 +98,7 @@ export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[]
|
|
|
78
98
|
const root: Root[] = [
|
|
79
99
|
...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
|
|
80
100
|
...[...folderOrder].map(([name, order]) => ({
|
|
81
|
-
item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(
|
|
101
|
+
item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderMeta.get(name) ?? {}) } as TreeItem,
|
|
82
102
|
order,
|
|
83
103
|
})),
|
|
84
104
|
]
|
|
@@ -94,12 +114,14 @@ export function flatten(tree: TreeItem[]): string[] {
|
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
/** The wire shape of a tree write (`POST boards/reorder`): plain strings for root boards,
|
|
97
|
-
* `{ folder, boards }` for folders - what the sidebar posts and what the server validates.
|
|
98
|
-
|
|
117
|
+
* `{ folder, boards }` for folders - what the sidebar posts and what the server validates.
|
|
118
|
+
* A folder's title and description ride with it (they live in the registry the write
|
|
119
|
+
* rewrites); a board's title lives in its own file and never rides the tree. */
|
|
120
|
+
export type WireItem = string | { folder: string; boards: string[]; title?: string; description?: string }
|
|
99
121
|
export const toWire = (tree: TreeItem[]): WireItem[] =>
|
|
100
|
-
tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...(it
|
|
122
|
+
tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...folderExtras(it) }))
|
|
101
123
|
export const fromWire = (wire: WireItem[]): TreeItem[] =>
|
|
102
|
-
wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...(w
|
|
124
|
+
wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...folderExtras(w) }))
|
|
103
125
|
|
|
104
126
|
/** Validate a wire tree off the network. Returns the error, or null when it is sound:
|
|
105
127
|
* every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
|
|
@@ -118,8 +140,9 @@ export function validateWire(wire: unknown): string | null {
|
|
|
118
140
|
for (const w of wire) {
|
|
119
141
|
if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
|
|
120
142
|
if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
|
|
121
|
-
const { folder, boards: kids, description } = w as { folder?: unknown; boards?: unknown; description?: unknown }
|
|
143
|
+
const { folder, boards: kids, title, description } = w as { folder?: unknown; boards?: unknown; title?: unknown; description?: unknown }
|
|
122
144
|
if (!isBoardName(folder)) return 'invalid folder name in tree'
|
|
145
|
+
if (title !== undefined && (typeof title !== 'string' || Array.from(title).length > TITLE_MAX)) return 'invalid folder title'
|
|
123
146
|
if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
|
|
124
147
|
if (folders.has(folder)) return `folder "${folder}" appears twice`
|
|
125
148
|
folders.add(folder)
|
|
@@ -163,35 +186,59 @@ export function takeBoard(t: TreeItem[], board: string): { list: string | null;
|
|
|
163
186
|
export type Drag = { kind: TreeItem['kind']; name: string }
|
|
164
187
|
export type Drop = { list: string | null; index: number } | { into: string }
|
|
165
188
|
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
gutter: boolean // pointer left of a child row's indent (the root gutter = outdent)
|
|
173
|
-
}
|
|
189
|
+
/** A row the sidebar rendered, measured - the list as the human sees it, top to bottom,
|
|
190
|
+
* the pinned `all-scenes` row included (it is the root's end). `open` is the folder's
|
|
191
|
+
* disclosure; `left` is the row's left edge, from which the child indent is measured. */
|
|
192
|
+
export interface Row { kind: TreeItem['kind']; name: string; parent: string | null; open?: boolean; top: number; bottom: number; left: number }
|
|
193
|
+
/** Px a board row indents inside a folder; the seams inside draw from there too. */
|
|
194
|
+
export const INDENT = 28
|
|
174
195
|
|
|
175
|
-
/** The drop target for a
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
/** The drop target for a pointer at (x, y) over the rendered rows - never null inside the
|
|
197
|
+
* list (the ends clamp), so a release anywhere over the sidebar lands somewhere the seam
|
|
198
|
+
* showed. Boards: the middle band of a folder header (a closed one: its lower band too) is
|
|
199
|
+
* INTO it; otherwise the nearest gap by row midlines, read from the row above it: after a
|
|
200
|
+
* root board or a closed folder = root; after an open folder's header, or between two of
|
|
201
|
+
* its boards = inside; after a folder's LAST board (or under an open empty folder) the gap
|
|
202
|
+
* is shared between the folder and the root, and the row under the pointer decides: still
|
|
203
|
+
* over that last board (that header), at or right of the indent the seams draw at = inside;
|
|
204
|
+
* over the root row below it, or in the gutter = root, after the folder. Folders: root gaps
|
|
205
|
+
* only, each root item one block. */
|
|
206
|
+
export function resolveDrop(t: TreeItem[], d: Drag, rows: Row[], x: number, y: number): Drop | null {
|
|
207
|
+
const mid = (r: { top: number; bottom: number }) => (r.top + r.bottom) / 2
|
|
208
|
+
if (d.kind === 'folder') {
|
|
209
|
+
const blocks: { top: number; bottom: number }[] = []
|
|
210
|
+
for (const r of rows) {
|
|
211
|
+
if (r.parent && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
|
|
212
|
+
blocks.push({ top: r.top, bottom: r.bottom })
|
|
213
|
+
}
|
|
214
|
+
let g = 0
|
|
215
|
+
for (const b of blocks) if (y >= mid(b)) g++
|
|
216
|
+
return { list: null, index: Math.min(g, t.length) }
|
|
217
|
+
}
|
|
218
|
+
for (const r of rows) {
|
|
219
|
+
if (r.kind !== 'folder' || y < r.top || y >= r.bottom) continue
|
|
220
|
+
const f = (y - r.top) / (r.bottom - r.top)
|
|
221
|
+
if (f >= 0.25 && (f < 0.75 || !r.open)) return { into: r.name }
|
|
184
222
|
}
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
let g = 0
|
|
224
|
+
for (const r of rows) if (y >= mid(r)) g++
|
|
225
|
+
const above = rows[g - 1], below = rows[g]
|
|
226
|
+
if (!above) return { list: null, index: 0 }
|
|
227
|
+
if (above.name === 'all-scenes') return { list: null, index: t.length }
|
|
228
|
+
const inside = (folder: string, index: number, alt: Drop): Drop => (y < above.bottom && x >= above.left + INDENT ? { list: folder, index } : alt)
|
|
229
|
+
if (above.kind === 'board') {
|
|
230
|
+
if (!above.parent) { const i = rootIndex(t, 'board', above.name); return i < 0 ? null : { list: null, index: i + 1 } }
|
|
231
|
+
const fi = rootIndex(t, 'folder', above.parent)
|
|
232
|
+
const i = folderIn(t, above.parent)?.boards.indexOf(above.name) ?? -1
|
|
233
|
+
if (fi < 0 || i < 0) return null
|
|
234
|
+
if (below?.kind === 'board' && below.parent === above.parent) return { list: above.parent, index: i + 1 }
|
|
235
|
+
return inside(above.parent, i + 1, { list: null, index: fi + 1 })
|
|
192
236
|
}
|
|
193
|
-
const
|
|
194
|
-
|
|
237
|
+
const fi = rootIndex(t, 'folder', above.name)
|
|
238
|
+
if (fi < 0) return null
|
|
239
|
+
if (!above.open) return { list: null, index: fi + 1 }
|
|
240
|
+
if (below?.kind === 'board' && below.parent === above.name) return { list: above.name, index: 0 }
|
|
241
|
+
return inside(above.name, 0, { list: null, index: fi + 1 })
|
|
195
242
|
}
|
|
196
243
|
|
|
197
244
|
/** A target that would leave the item where it is: nothing to show, nothing to drop. Into
|
|
@@ -248,25 +295,36 @@ export function moveBoard(tree: TreeItem[], board: string, folder: string | null
|
|
|
248
295
|
}
|
|
249
296
|
|
|
250
297
|
/** A new folder at root `index`, holding `board` (pulled from wherever it sat) when given. */
|
|
251
|
-
export function createFolder(tree: TreeItem[], name: string, index: number, board?: string): TreeItem[] | null {
|
|
298
|
+
export function createFolder(tree: TreeItem[], name: string, index: number, board?: string, title?: string): TreeItem[] | null {
|
|
252
299
|
if (foldersIn(tree).includes(name)) return null
|
|
253
300
|
const next = cloneTree(tree)
|
|
254
301
|
const boards: string[] = []
|
|
255
302
|
if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
|
|
256
|
-
next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards })
|
|
303
|
+
next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards, ...(title ? { title } : {}) })
|
|
257
304
|
return next
|
|
258
305
|
}
|
|
259
306
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
307
|
+
/** Retitle a folder: what humans see (''= clear, back to the Title-Cased slug). The slug is
|
|
308
|
+
* its identity - `folder:` on every member board, the registry key - and never changes here. */
|
|
309
|
+
export function retitleFolder(tree: TreeItem[], name: string, title: string): TreeItem[] | null {
|
|
263
310
|
const next = cloneTree(tree)
|
|
264
|
-
const f = folderIn(next,
|
|
311
|
+
const f = folderIn(next, name)
|
|
265
312
|
if (!f) return null
|
|
266
|
-
f.
|
|
313
|
+
if (title) f.title = title; else delete f.title
|
|
267
314
|
return next
|
|
268
315
|
}
|
|
269
316
|
|
|
317
|
+
/** The slug a NEW folder gets from the title the human typed: `slugify(title)`, then `-2`,
|
|
318
|
+
* `-3`, ... past a taken one; `fallback` (and `fallback-2`, ...) when nothing survives
|
|
319
|
+
* slugifying ("🚀"). Slugs are minted once - a rename changes the title, never the slug. */
|
|
320
|
+
export function slugFor(title: string, taken: string[], fallback = 'folder'): string {
|
|
321
|
+
const s = slugify(title)
|
|
322
|
+
if (s && !taken.includes(s)) return s
|
|
323
|
+
const base = s || fallback
|
|
324
|
+
if (!s && !taken.includes(base)) return base
|
|
325
|
+
for (let i = 2; ; i++) { const c = `${base.slice(0, NAME_MAX - 1 - String(i).length)}-${i}`; if (!taken.includes(c)) return c }
|
|
326
|
+
}
|
|
327
|
+
|
|
270
328
|
/** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
|
|
271
329
|
export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
|
|
272
330
|
const next = cloneTree(tree)
|
|
@@ -81,7 +81,10 @@ planning. The human should see the request land on the canvas within the first m
|
|
|
81
81
|
lit frame, never before one.
|
|
82
82
|
3. Build. Independent frames can go in parallel - one subagent per frame, each marking
|
|
83
83
|
its own; frames that depend on one another go in order.
|
|
84
|
-
4. **
|
|
84
|
+
4. **Look before you say done**: `npx marver shot --scene <scene>` (or `<scene/frame ...>`,
|
|
85
|
+
`--all`) renders the frames headless in one go - one PNG path per line - and you READ
|
|
86
|
+
the PNGs. No shell? instructions/jam.md has the file-drop way (`{"scene":"..."}`).
|
|
87
|
+
5. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
|
|
85
88
|
self-expire (default 10 min; `--ttl <min>` up to 30) - re-run `start` on long jobs,
|
|
86
89
|
and never lean on expiry instead of `done`.
|
|
87
90
|
|
|
@@ -133,8 +136,15 @@ Report where the request came from: chat requests get chat replies; only comment
|
|
|
133
136
|
## Orientation
|
|
134
137
|
- design/manifest.json is the canvas with its purpose: the project (name, description),
|
|
135
138
|
every folder and board in sidebar order, every scene and frame - each with its
|
|
136
|
-
`description` when one was written
|
|
137
|
-
fresh. `npx marver boards` prints the
|
|
139
|
+
`description` when one was written, and its `title` when the human (or you) named it.
|
|
140
|
+
Read it before exploring; `marver dev` keeps it fresh. `npx marver boards` prints the
|
|
141
|
+
boards part as a tree.
|
|
142
|
+
- **Names vs titles.** A board's file name, a folder's `name`, a scene's directory are
|
|
143
|
+
IDENTITIES: slugs you address, that publish.json, URLs and comment threads hold, and
|
|
144
|
+
that never move on a rename. `title` is what humans see - free text (casing,
|
|
145
|
+
punctuation, emoji) on the board JSON, the `_folders.json` entry, the brief's front
|
|
146
|
+
matter; frames have `meta.title`. When the human says "the Checkout A/B board", the
|
|
147
|
+
manifest maps that title to its slug. Rename a slug only when asked, as one refactor.
|
|
138
148
|
- **Descriptions.** Every object takes one optional `description`: one sentence, what it
|
|
139
149
|
is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
|
|
140
150
|
in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
|
|
@@ -81,7 +81,10 @@ planning. The human should see the request land on the canvas within the first m
|
|
|
81
81
|
lit frame, never before one.
|
|
82
82
|
3. Build. Independent frames can go in parallel - one subagent per frame, each marking
|
|
83
83
|
its own; frames that depend on one another go in order.
|
|
84
|
-
4. **
|
|
84
|
+
4. **Look before you say done**: `npx marver shot --scene <scene>` (or `<scene/frame ...>`,
|
|
85
|
+
`--all`) renders the frames headless in one go - one PNG path per line - and you READ
|
|
86
|
+
the PNGs. No shell? instructions/jam.md has the file-drop way (`{"scene":"..."}`).
|
|
87
|
+
5. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
|
|
85
88
|
self-expire (default 10 min; `--ttl <min>` up to 30) - re-run `start` on long jobs,
|
|
86
89
|
and never lean on expiry instead of `done`.
|
|
87
90
|
|
|
@@ -132,8 +135,15 @@ Report where the request came from: chat requests get chat replies; only comment
|
|
|
132
135
|
## Orientation
|
|
133
136
|
- design/manifest.json is the canvas with its purpose: the project (name, description),
|
|
134
137
|
every folder and board in sidebar order, every scene and frame - each with its
|
|
135
|
-
`description` when one was written
|
|
136
|
-
fresh. `npx marver boards` prints the
|
|
138
|
+
`description` when one was written, and its `title` when the human (or you) named it.
|
|
139
|
+
Read it before exploring; `marver dev` keeps it fresh. `npx marver boards` prints the
|
|
140
|
+
boards part as a tree.
|
|
141
|
+
- **Names vs titles.** A board's file name, a folder's `name`, a scene's directory are
|
|
142
|
+
IDENTITIES: slugs you address, that publish.json, URLs and comment threads hold, and
|
|
143
|
+
that never move on a rename. `title` is what humans see - free text (casing,
|
|
144
|
+
punctuation, emoji) on the board JSON, the `_folders.json` entry, the brief's front
|
|
145
|
+
matter; frames have `meta.title`. When the human says "the Checkout A/B board", the
|
|
146
|
+
manifest maps that title to its slug. Rename a slug only when asked, as one refactor.
|
|
137
147
|
- **Descriptions.** Every object takes one optional `description`: one sentence, what it
|
|
138
148
|
is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
|
|
139
149
|
in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
|
|
@@ -10,10 +10,20 @@ viewport and lays it out:
|
|
|
10
10
|
|
|
11
11
|
```json
|
|
12
12
|
{ "version": 1, "name": "checkout-compare", "order": 1, "auto": false,
|
|
13
|
+
"title": "Checkout A/B",
|
|
13
14
|
"description": "Cart step, direction A vs B side by side - B is the current favourite",
|
|
14
15
|
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
15
16
|
```
|
|
16
17
|
|
|
18
|
+
- **The file name is the board's identity** - what you, `publish.json`, URLs and
|
|
19
|
+
comment threads address (`board: checkout-compare`). It is a slug
|
|
20
|
+
(`^[a-z0-9][a-z0-9-]*$`) and never moves on a rename.
|
|
21
|
+
- `title` - what humans SEE: free text, any casing, punctuation, emoji ("MVP", "UI",
|
|
22
|
+
"Checkout (v2) 🛒"). Optional: without one the sidebar Title-Cases the slug
|
|
23
|
+
(`checkout-compare` → "Checkout Compare"), which is fine for most boards. Write a
|
|
24
|
+
title when the slug would read wrong (`mvp` → "Mvp") or when the human names it.
|
|
25
|
+
The human's Rename in the sidebar edits the title only; the manifest carries both, so
|
|
26
|
+
"the Checkout A/B board" resolves to `checkout-compare`.
|
|
17
27
|
- `description` - one sentence on what the board is for and where it stands. It is
|
|
18
28
|
how a later session (or the human's next agent) knows this board without opening
|
|
19
29
|
it; it lands in design/manifest.json. Write it at creation, keep it true.
|
|
@@ -28,8 +38,9 @@ viewport and lays it out:
|
|
|
28
38
|
orienting board (an overview or the primary flow) - never a giant one. Boards
|
|
29
39
|
without an `order` sort after the ranked ones, by name. Set `order` deliberately on
|
|
30
40
|
every curated board; it is the first impression. The human can also drag-reorder boards
|
|
31
|
-
in the sidebar (which rewrites `order`),
|
|
32
|
-
boards into folders (below) - so 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.
|
|
33
44
|
- `auto: false` boards show exactly their list. `all-scenes` is auto-managed (it holds
|
|
34
45
|
EVERY frame, so it is the heavy one) and always sinks to the BOTTOM of the switcher -
|
|
35
46
|
never the landing board, and never write its file.
|
|
@@ -64,12 +75,14 @@ Files are the truth, and two files carry it:
|
|
|
64
75
|
|
|
65
76
|
```json
|
|
66
77
|
{ "version": 1, "folders": [
|
|
67
|
-
{ "name": "research", "order": 1, "description": "The thinking behind the live boards - specs, flows, references" },
|
|
78
|
+
{ "name": "research", "order": 1, "title": "R&D", "description": "The thinking behind the live boards - specs, flows, references" },
|
|
68
79
|
{ "name": "archive", "order": 3, "description": "Retired directions and scene versions, oldest first" } ] }
|
|
69
80
|
```
|
|
70
81
|
|
|
71
|
-
A folder's `
|
|
72
|
-
|
|
82
|
+
A folder's `name` is its slug - the identity its boards point at with `folder`; its
|
|
83
|
+
`title` (optional, free text) is what humans see, exactly as on a board; its
|
|
84
|
+
`description` says what belongs in it - the next session files boards right without
|
|
85
|
+
asking.
|
|
73
86
|
|
|
74
87
|
It exists so an EMPTY folder can exist and so a folder has a rank at the root.
|
|
75
88
|
A folder a board names but the registry lacks is still real (it sorts after the
|
|
@@ -91,8 +104,12 @@ The moves, each a file edit, so the files always agree:
|
|
|
91
104
|
an `order` among the top-level items.
|
|
92
105
|
- **Rank** folders and boards: `order` on the board (among its siblings) and on the
|
|
93
106
|
registry entry (among the top-level items). Renumber the siblings you touch.
|
|
94
|
-
- **
|
|
95
|
-
|
|
107
|
+
- **Retitle** a folder (or a board): set `title` on the registry entry (on the board
|
|
108
|
+
file). **Rename a slug** - a folder's `name`, a board's file name - only when asked,
|
|
109
|
+
and as one refactor: a folder slug is on every member's `folder` field (rewrite them
|
|
110
|
+
all, AND the registry entry - a registry rename alone leaves the members in the old,
|
|
111
|
+
implied folder); a board file name is in `publish.json`, in its comment threads and in
|
|
112
|
+
every path anyone copied. A title does what a rename usually wanted.
|
|
96
113
|
- **Delete** a folder: remove `folder` from every member, then its registry entry.
|
|
97
114
|
Folders organise, never own: deleting one never deletes a board.
|
|
98
115
|
- The **landing board** is the first board in sidebar order, folders included -
|
|
@@ -105,8 +122,9 @@ last. Propose the grouping in one sentence and do it; keep folder names short an
|
|
|
105
122
|
plain.
|
|
106
123
|
|
|
107
124
|
The human does all of this too - from the sidebar: New folder (right-click the Boards
|
|
108
|
-
header, or its `+`), Rename
|
|
109
|
-
into and out of folders, folders among
|
|
125
|
+
header, or its `+`), Rename (the title - slugs never move from the sidebar), Delete
|
|
126
|
+
folder, "Move to …" on a board, and DRAG: boards into and out of folders, folders among
|
|
127
|
+
boards. Each drag rewrites `order` (and
|
|
110
128
|
`folder`) on the boards it touches and the registry - the shell owns those fields
|
|
111
129
|
while the canvas is open, exactly as it owns `order`; write membership and new
|
|
112
130
|
folders freely, and never rewrite an arrangement the human just made. The shell
|