@marver-design/marver 0.14.0 → 0.16.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 (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -4
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
  6. package/dist/cli.mjs +15 -6
  7. package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
  9. package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
  11. package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
  12. package/dist/poster-DOY7pax8.mjs +143 -0
  13. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  14. package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
  15. package/docs/live-jam.md +6 -2
  16. package/docs/publish.md +4 -1
  17. package/docs/slides.md +9 -3
  18. package/package.json +1 -1
  19. package/src/client/content/chart.tsx +62 -24
  20. package/src/client/content/index.tsx +5 -2
  21. package/src/client/content/video.tsx +132 -35
  22. package/src/client/frame-host/bridge.js +6 -1
  23. package/src/client/shell/App.tsx +33 -247
  24. package/src/client/shell/BoardList.tsx +408 -0
  25. package/src/client/shell/ContextMenu.tsx +59 -0
  26. package/src/client/shell/icons.tsx +7 -0
  27. package/src/client/shell/store.ts +156 -48
  28. package/src/client/shell/styles.css +42 -4
  29. package/src/shared/board-tree.ts +285 -0
  30. package/templates/AGENTS-embedded.md +35 -7
  31. package/templates/AGENTS-studio.md +35 -7
  32. package/templates/instructions/boards.md +120 -7
  33. package/templates/instructions/craft.md +21 -0
  34. package/templates/instructions/discover.md +7 -2
  35. package/templates/instructions/iterate.md +114 -18
  36. package/templates/instructions/jam.md +18 -2
  37. package/templates/instructions/review.md +4 -0
  38. package/templates/instructions/shape.md +16 -2
  39. package/templates/instructions/slides.md +5 -1
  40. package/templates/instructions/welcome.md +4 -1
  41. package/templates/instructions/wireframe.md +3 -0
  42. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -12,6 +12,8 @@ import shData from 'virtual:sh-data'
12
12
  * is where `/` opens - never a synthesized aggregate of a filtered build. */
13
13
  const DATA: {
14
14
  manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
15
+ /** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */
16
+ tree?: TreeItem[]
15
17
  /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
16
18
  policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
17
19
  } | null = shData
@@ -123,19 +125,42 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
123
125
  export { cap, humanize } from './labels.ts'
124
126
  import { cap, humanize } from './labels.ts'
125
127
  import { canAutoReload } from './canvas/ready-watch.ts'
128
+ import { buildTree, flatten, toWire, type TreeItem } from '../../shared/board-tree.ts'
126
129
 
127
- /** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
128
- * name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
130
+ /** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
131
+ * the folder registry (null = there was no file). */
132
+ export interface TreeBase { boards: Record<string, string>; folders: string | null }
133
+ export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase }
134
+
135
+ /** The sidebar tree: root boards and folders in rank order, each folder's boards inside
136
+ * (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
137
+ * is pinned last by the callers. Throws on transport failure and on a malformed registry
138
+ * (the server's 422 message) - callers keep their last known tree. */
139
+ 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 } }
141
+ 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 }[]>,
143
+ fetch(`${ROUTE}/api/folders`).then(async (r) => {
144
+ const j = await r.json() as { folders?: { name: string; order?: number }[]; sha256?: string | null; error?: string }
145
+ if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
146
+ return j
147
+ }),
148
+ ])
149
+ return {
150
+ tree: buildTree(boards, reg.folders ?? []),
151
+ base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
152
+ }
153
+ }
154
+
155
+ /** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
156
+ * the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
129
157
  * Throws on transport failure - callers keep their last known list. */
130
158
  export async function fetchBoardNames(): Promise<string[]> {
131
159
  if (DATA) return DATA.names
132
- const list: { name: string; order?: number }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
133
- const curated = list
134
- .filter((b) => b.name !== 'all-scenes')
135
- .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
136
- .map((b) => b.name)
137
- return [...curated, 'all-scenes']
160
+ return [...flatten((await fetchBoardTree()).tree), 'all-scenes']
138
161
  }
162
+ /** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
163
+ export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
139
164
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
140
165
  export const boardLabel = (n: string) => humanize(n)
141
166
 
@@ -251,6 +276,8 @@ interface State {
251
276
  playUpdateRevision: string | null // a revision arrived while play is open
252
277
  playNav: number // bumps to reload the play stage on demand
253
278
  pathPulse: number // bumps on each successful path copy - flashes the toolbar icon into a check
279
+ imagePulse: number // bumps on each successful image copy - same flash, the images-square icon
280
+ imageBusy: boolean // a copy-as-image render is in flight (one at a time)
254
281
 
255
282
  boot(): Promise<boolean>
256
283
  applyManifest(m: Manifest): void
@@ -278,8 +305,9 @@ interface State {
278
305
  resizeSelected(name: string | null): void
279
306
  switchBoard(name: string): Promise<void>
280
307
  renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
281
- reorderBoards(order: string[]): Promise<boolean>
308
+ arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
282
309
  pulsePath(): void
310
+ copyFrameImage(scale: 2 | 4): void
283
311
  setScale(s: number): void
284
312
  togglePanel(): void
285
313
  setTheme(theme: string): void
@@ -314,8 +342,18 @@ export const useStore = create<State>((set, get) => {
314
342
  let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit
315
343
  let loadSeq = 0 // stale boot() responses never overwrite a newer board
316
344
  let switchSeq = 0 // last click wins when board switches race
317
- let renameLock = false // while a board file is being renamed, autosave must not fire against the OLD name (a mustExist PUT would 409-gone and clear dirty, losing the in-flight edit)
318
- const scheduleSave = () => { editRev++; if (renameLock) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
345
+ // While a board file is being renamed or rewritten by a structural write (rename, the
346
+ // sidebar's tree arrange), autosave must not fire against it (a mustExist PUT would
347
+ // 409-gone and clear dirty, losing the in-flight edit; a stale baseHash would 409 and
348
+ // reload). A COUNT, not a flag: two holders never release each other. Structural writes
349
+ // also run one at a time through `structChain`, so a rename can never interleave a
350
+ // tree write on the same file.
351
+ let saveHolds = 0
352
+ const holdSaves = () => { saveHolds++; clearTimeout(saveTimer) }
353
+ const releaseSaves = () => { if (--saveHolds === 0 && get().dirty) scheduleSave() }
354
+ let structChain: Promise<unknown> = Promise.resolve()
355
+ const structural = <T,>(fn: () => Promise<T>): Promise<T> => { const p = structChain.then(fn, fn); structChain = p.catch(() => {}); return p }
356
+ const scheduleSave = () => { editRev++; if (saveHolds > 0) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
319
357
 
320
358
  // One cancelable, BOARD-SCOPED reflow after content measurements settle.
321
359
  // The captured board name is the generation guard - a debounce surviving a board
@@ -564,7 +602,7 @@ export const useStore = create<State>((set, get) => {
564
602
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
565
603
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
566
604
  panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
567
- pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0,
605
+ pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
568
606
 
569
607
  async boot() {
570
608
  const seq = ++loadSeq
@@ -613,43 +651,66 @@ export const useStore = create<State>((set, get) => {
613
651
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
614
652
  },
615
653
 
616
- async renameBoard(from, to) {
617
- const active = from === get().board
618
- // Renaming the ACTIVE board renames its file out from under the autosave. Flush any
619
- // pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
620
- if (active) {
621
- let ok = true
622
- for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
623
- if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
624
- // hold autosave across the round-trip: an edit landing mid-rename must NOT save to the
625
- // old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
626
- renameLock = true
627
- clearTimeout(saveTimer)
628
- }
629
- // Release the lock and resume autosave. Re-read the LIVE board, not the captured `active`:
630
- // a board switch may have completed while we awaited, so only the board that is STILL
631
- // `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
632
- const release = () => { if (active) { renameLock = false; if (get().dirty) scheduleSave() } }
633
- let res: Response
634
- try { res = await postOwner('boards/rename', { from, to }) }
635
- catch { release(); return { ok: false, error: 'could not reach the dev server' } }
636
- if (!res.ok) {
637
- release()
638
- const e = await res.json().catch(() => ({} as { error?: string }))
639
- return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
640
- }
641
- // Content is byte-identical after a file move, so boardHash still matches for the next
642
- // autosave; only the name (and the URL, via the board-change subscription) changes. Set
643
- // the new name BEFORE releasing the lock so a resumed save targets `to`.
644
- if (active && get().board === from) set({ board: to })
645
- release()
646
- return { ok: true }
654
+ renameBoard(from, to) {
655
+ return structural(async () => {
656
+ const active = from === get().board
657
+ // Renaming the ACTIVE board renames its file out from under the autosave. Flush any
658
+ // pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
659
+ if (active) {
660
+ let ok = true
661
+ for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
662
+ if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
663
+ // hold autosave across the round-trip: an edit landing mid-rename must NOT save to the
664
+ // old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
665
+ holdSaves()
666
+ }
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
+ try {
671
+ let res: Response
672
+ try { res = await postOwner('boards/rename', { from, to }) }
673
+ catch { return { ok: false, error: 'could not reach the dev server' } }
674
+ if (!res.ok) {
675
+ const e = await res.json().catch(() => ({} as { error?: string }))
676
+ return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
677
+ }
678
+ // Content is byte-identical after a file move, so boardHash still matches for the next
679
+ // autosave; only the name (and the URL, via the board-change subscription) changes. Set
680
+ // the new name BEFORE releasing the hold so a resumed save targets `to`.
681
+ if (active && get().board === from) set({ board: to })
682
+ return { ok: true }
683
+ } finally { if (active) releaseSaves() } // whatever happened, autosave resumes
684
+ })
647
685
  },
648
686
 
649
- async reorderBoards(order) {
650
- let res: Response
651
- try { res = await postOwner('boards/reorder', { order }) } catch { return false }
652
- return res.ok
687
+ // The whole sidebar tree in one write: order, membership, the folders themselves. The
688
+ // write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
689
+ // rename: flush the pending autosave first, hold autosave across the round-trip, then
690
+ // advance boardHash to the hash the server answered - the autosave's CAS token stays
691
+ // true and the watcher's echo is filtered as our own. `base` carries the hashes the
692
+ // sidebar last saw; a 409 (`stale`) means someone else wrote first - refetch and replay.
693
+ arrangeBoards(tree, base) {
694
+ return structural(async () => {
695
+ let ok = true
696
+ for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
697
+ if (get().dirty) return { ok: false as const, stale: false, error: 'unsaved changes - try again' }
698
+ holdSaves()
699
+ try {
700
+ // the active board's hash is freshest in the store (an autosave may have landed since
701
+ // the sidebar looked); every other board's is the sidebar's
702
+ const active = get().board
703
+ const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
704
+ let res: Response
705
+ try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
706
+ catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
707
+ const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
708
+ if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
709
+ const sha = body?.sha256?.boards?.[get().board]
710
+ if (sha) set({ boardHash: sha })
711
+ return { ok: true as const }
712
+ } finally { releaseSaves() } // whatever happened, autosave resumes
713
+ })
653
714
  },
654
715
 
655
716
  applyManifest(m) {
@@ -1017,6 +1078,53 @@ export const useStore = create<State>((set, get) => {
1017
1078
  },
1018
1079
  setScale(scale) { set({ scale }) },
1019
1080
  pulsePath() { set((s) => ({ pathPulse: s.pathPulse + 1 })) },
1081
+ // Copy the ONE selected frame to the clipboard as a PNG - the dev server's headless
1082
+ // renderer (/api/shot, the same picture `marver shot` gives an agent) sized to what the
1083
+ // node shows, at 2x (or 4x). The ClipboardItem takes a PROMISE: clipboard.write runs
1084
+ // inside the click/keydown gesture and the browser waits for the bytes - a plain
1085
+ // await-then-write would have lost the transient activation during the 1-4s render.
1086
+ copyFrameImage(scale) {
1087
+ const s = get()
1088
+ if (PUBLISHED || s.imageBusy || s.selection.length !== 1) return
1089
+ const node = s.nodes.find((n) => n.key === s.selection[0])
1090
+ if (!node || node.missing) return
1091
+ const frame = s.frameFor(node)
1092
+ if (!frame) { s.toast('frame is still indexing - try again in a second'); return }
1093
+ const CI = (globalThis as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
1094
+ if (!CI || !navigator.clipboard?.write || (CI.supports && !CI.supports('image/png'))) { s.toast("this browser can't put images on the clipboard - use Chrome, Edge or Safari"); return }
1095
+ set({ imageBusy: true })
1096
+ const done = () => set({ imageBusy: false })
1097
+ const qs = new URLSearchParams({ frame: frame.id, theme: node.theme, scale: String(scale), w: String(Math.round(node.w)), h: String(Math.round(node.h)), format: 'png' })
1098
+ // the render is serialized server-side behind any CLI/jam shots; a minute is the ceiling
1099
+ // before the UI gives up on this copy (the server's own watchdog is 45s per shot)
1100
+ const ctl = new AbortController()
1101
+ const timer = setTimeout(() => ctl.abort(), 60_000)
1102
+ let meta: { scale?: number; note?: string } = {}
1103
+ const png = fetch(`${ROUTE}/api/shot?${qs}`, { headers: { 'x-mv-c': csrf() }, signal: ctl.signal }).then(async (r) => {
1104
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error ?? `shot failed (${r.status})`)
1105
+ try { meta = JSON.parse(atob((r.headers.get('x-mv-shot') ?? '').replace(/-/g, '+').replace(/_/g, '/'))) } catch { /* summary is advisory */ }
1106
+ return r.blob()
1107
+ }).finally(() => clearTimeout(timer))
1108
+ let renderErr = ''
1109
+ png.catch((e: Error) => { renderErr = e.name === 'AbortError' ? 'timed out - the renderer is busy' : e.message })
1110
+ const fail = (err: unknown) => {
1111
+ done()
1112
+ // a prompt clipboard refusal (no gesture, focus lost) must not wait a minute on the
1113
+ // render: abandon the fetch, toast now. A render failure surfaces its own cause.
1114
+ if (!renderErr) ctl.abort()
1115
+ s.toast(renderErr ? `render failed - ${renderErr}` : (err as Error)?.name === 'NotAllowedError' ? 'copy blocked - click the canvas first' : `copy failed - ${(err as Error)?.message ?? err}`)
1116
+ }
1117
+ try {
1118
+ navigator.clipboard.write([new CI({ 'image/png': png })]).then(
1119
+ () => {
1120
+ done()
1121
+ const used = meta.scale ?? scale
1122
+ s.toast(used < scale ? `image copied at ${used}x - frame too tall for ${scale}x` : scale === 4 ? 'image copied (4x)' : 'image copied')
1123
+ set((st) => ({ imagePulse: st.imagePulse + 1 }))
1124
+ },
1125
+ fail)
1126
+ } catch (err) { fail(err) } // a synchronous constructor/type error must not wedge busy
1127
+ },
1020
1128
  togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
1021
1129
  // global theme = the VIEW preference: persists across boards + reloads, clears
1022
1130
  // per-frame pins. Frames declaring meta.theme keep their mode (they only work there).
@@ -1091,7 +1199,7 @@ export const useStore = create<State>((set, get) => {
1091
1199
  save() {
1092
1200
  // a rename is moving this board's file - defer every save so nothing writes to the OLD
1093
1201
  // name mid-move (renameBoard reschedules once the move commits under the new name)
1094
- if (renameLock) return Promise.resolve(false)
1202
+ if (saveHolds > 0) return Promise.resolve(false)
1095
1203
  // a resize gesture in flight = torn state (new sizes, pre-recipe positions):
1096
1204
  // even a PREVIOUSLY scheduled timer must defer to the gesture-end save
1097
1205
  if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
@@ -413,6 +413,12 @@ body.sh-commenting .sh-lean { opacity: 0 }
413
413
  justify-content: center; padding: 0 6px; margin: 0 2px 0 3px }
414
414
  .sh-ctx button.on { background: var(--glass-accent-bg); color: var(--glass-accent) }
415
415
  .sh-ctx button:hover:not(.on) { background: var(--glass-hover); color: var(--glass-ink) }
416
+ /* multi-select / no-op states read as quiet, not broken */
417
+ .sh-ctx button:disabled { cursor: default; opacity: .45 }
418
+ .sh-ctx button:disabled:hover { background: none; color: var(--glass-ink-2) }
419
+ /* copy-as-image while the headless render runs (1-4s): a slow breathe on the icon */
420
+ .sh-ctx button.busy { opacity: 1; color: var(--glass-accent); animation: sh-ctx-breathe 1.1s ease-in-out infinite }
421
+ @keyframes sh-ctx-breathe { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
416
422
  .sh-ctx .sep { width: 1px; height: 14px; background: var(--glass-brd); margin: 0 3px }
417
423
 
418
424
  /* error / missing cards (inside the frame body) */
@@ -499,9 +505,38 @@ body.sh-commenting .sh-lean { opacity: 0 }
499
505
  .sh-panel .it.board.drop-before::after { top: -1px }
500
506
  .sh-panel .it.board.drop-after::after { bottom: -1px }
501
507
  /* inline rename: the input wears the row's own type so the swap is seamless */
502
- .sh-panel .it.board.editing { cursor: default }
503
- .sh-panel .it.board.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
508
+ .sh-panel .it.board.editing, .sh-panel .it.folder.editing { cursor: default }
509
+ .sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
504
510
  color: var(--glass-ink); font: inherit }
511
+ .sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
512
+ /* folders: one level, same row grammar as boards (idle icon tertiary, count tertiary). A folder
513
+ holding the current board wears the ancestor wash so the active board's home survives a
514
+ collapse; its boards indent one step. Folder rows share the board seam rules; dropping INTO a
515
+ folder lights the whole row (wash + brand-blue ring) instead of a seam. */
516
+ .sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
517
+ .sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
518
+ .sh-panel .it.folder.dragging { opacity: .4 }
519
+ .sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
520
+ .sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
521
+ content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
522
+ background: #0088ff; pointer-events: none }
523
+ .sh-panel .it.folder.drop-before::after { top: -1px }
524
+ .sh-panel .it.folder.drop-after::after { bottom: -1px }
525
+ .sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
526
+ .sh-panel .it.folder.drop-into svg { color: #0088ff }
527
+ .sh-panel .it.board.in-folder { padding-left: 28px }
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 */
530
+ .sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
531
+ /* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
532
+ .sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
533
+ .sh-panel .sh-boards .hd span { flex: 1 }
534
+ /* a bare glyph, no button chrome (padding would shrink it): tertiary, pointer, full ink on hover */
535
+ .sh-panel .sh-hd-add { flex: none; color: var(--glass-ink-3); cursor: pointer; outline: none; border-radius: 4px;
536
+ opacity: 0; transition: opacity .15s ease, color .15s ease }
537
+ .sh-panel .sh-boards .hd:hover .sh-hd-add, .sh-panel .sh-hd-add:focus-visible { opacity: 1 }
538
+ .sh-panel .sh-hd-add:hover { color: var(--glass-ink) }
539
+ .sh-panel .sh-hd-add:focus-visible { box-shadow: 0 0 0 2px var(--accent) }
505
540
  /* frame rows: one indent in (aligned to the label line), quieter ink */
506
541
  .sh-panel .sub { display: flex; align-items: center; height: 28px; padding: 0 8px 0 31px; margin-bottom: 1px;
507
542
  color: var(--glass-ink-2); font-size: 13px; font-weight: 400; border-radius: 8px; cursor: pointer }
@@ -556,8 +591,11 @@ body.sh-commenting .sh-lean { opacity: 0 }
556
591
  cursor: pointer; text-align: left }
557
592
  .sh-menu button:hover { background: var(--glass-hover) }
558
593
  .sh-menu button span { flex: 1; text-transform: capitalize }
559
- /* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly) */
560
- .sh-ctxmenu button span { text-transform: none }
594
+ /* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly);
595
+ it scrolls rather than overflowing the window when the folder list grows long */
596
+ .sh-ctxmenu { max-height: calc(100vh - 16px); max-width: 240px; overflow-y: auto; scrollbar-width: thin }
597
+ .sh-ctxmenu button { flex: none; max-width: 100% }
598
+ .sh-ctxmenu button span { text-transform: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0 }
561
599
  .sh-menu .chk { color: var(--glass-accent) }
562
600
  .sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
563
601
  .sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Board folders - the pure tree shared by the sidebar, the dev API, the build and the
3
+ * tests. Files are the truth: a board says which folder it sits in (`folder` on the
4
+ * board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
5
+ * says which folders exist and where they rank at the root. One level only: folders
6
+ * hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
7
+ */
8
+
9
+ /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
10
+ export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
11
+ export const NAME_MAX = 64
12
+ export const isBoardName = (n: unknown): n is string => typeof n === 'string' && n.length >= 1 && n.length <= NAME_MAX && BOARD_NAME.test(n)
13
+
14
+ /** The folder registry beside the boards - underscore = infrastructure, never a board. */
15
+ export const FOLDERS_FILE = '_folders.json'
16
+ /** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
17
+ * off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
18
+ export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
19
+
20
+ export type Folder = { kind: 'folder'; name: string; boards: string[]; description?: string }
21
+ export type TreeItem = { kind: 'board'; name: string } | Folder
22
+
23
+ export interface BoardRow { name: string; order?: number; folder?: string }
24
+ export interface FolderRow { name: string; order?: number; description?: string }
25
+
26
+ /** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
27
+ export const DESCRIPTION_MAX = 300
28
+ export const readDescription = (v: unknown): string | undefined => {
29
+ if (typeof v !== 'string') return undefined
30
+ const s = v.trim().replace(/\s+/g, ' ').slice(0, DESCRIPTION_MAX)
31
+ return s || undefined
32
+ }
33
+
34
+ const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
35
+
36
+ /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
37
+ * malformed registry is an ERROR the human must fix (silently reading it as empty would
38
+ * let the next drag overwrite their folders), while a missing file is simply no folders. */
39
+ export function parseFolders(raw: unknown): FolderRow[] | string {
40
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
41
+ const { version, folders } = raw as { version?: unknown; folders?: unknown }
42
+ if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
43
+ if (!Array.isArray(folders)) return 'expected a "folders" array'
44
+ const out: FolderRow[] = []
45
+ const seen = new Set<string>()
46
+ for (const f of folders) {
47
+ const name = (f as { name?: unknown })?.name
48
+ if (!isBoardName(name)) return 'a folder needs a name - lowercase letters, numbers and dashes'
49
+ if (seen.has(name)) return `folder "${name}" is listed twice`
50
+ seen.add(name)
51
+ const o = (f as { order?: unknown }).order
52
+ const d = readDescription((f as { description?: unknown }).description)
53
+ out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(d ? { description: d } : {}) })
54
+ }
55
+ return out
56
+ }
57
+
58
+ /** Sidebar order from the files. Root: boards with no folder + every folder (registered
59
+ * 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. */
61
+ export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
62
+ const folderOrder = new Map<string, number | undefined>()
63
+ const folderDesc = new Map<string, string>()
64
+ for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order); if (f.description) folderDesc.set(f.name, f.description) }
65
+ const members = new Map<string, BoardRow[]>()
66
+ const rootBoards: BoardRow[] = []
67
+ for (const b of boards) {
68
+ if (!isBoardName(b.name) || b.name === 'all-scenes') continue
69
+ const folder = isBoardName(b.folder) ? b.folder : undefined
70
+ if (!folder) { rootBoards.push(b); continue }
71
+ if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
72
+ const list = members.get(folder) ?? []
73
+ list.push(b)
74
+ members.set(folder, list)
75
+ }
76
+ const byRank = (a: BoardRow, b: BoardRow) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name)
77
+ type Root = { item: TreeItem; order: number | undefined }
78
+ const root: Root[] = [
79
+ ...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
80
+ ...[...folderOrder].map(([name, order]) => ({
81
+ item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderDesc.has(name) ? { description: folderDesc.get(name) } : {}) } as TreeItem,
82
+ order,
83
+ })),
84
+ ]
85
+ 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))
86
+ return root.map((r) => r.item)
87
+ }
88
+
89
+ /** Every board in reading order - the order the switchers and the landing pick use. */
90
+ export function flatten(tree: TreeItem[]): string[] {
91
+ const out: string[] = []
92
+ for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
93
+ return out
94
+ }
95
+
96
+ /** The wire shape of a tree write (`POST boards/reorder`): plain strings for root boards,
97
+ * `{ folder, boards }` for folders - what the sidebar posts and what the server validates. */
98
+ export type WireItem = string | { folder: string; boards: string[]; description?: string }
99
+ export const toWire = (tree: TreeItem[]): WireItem[] =>
100
+ tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...(it.description ? { description: it.description } : {}) }))
101
+ export const fromWire = (wire: WireItem[]): TreeItem[] =>
102
+ wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...(w.description ? { description: w.description } : {}) }))
103
+
104
+ /** Validate a wire tree off the network. Returns the error, or null when it is sound:
105
+ * every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
106
+ * nesting (a folder's boards are strings), bounded. */
107
+ export const TREE_MAX_BOARDS = 200
108
+ export const TREE_MAX_FOLDERS = 50
109
+ export function validateWire(wire: unknown): string | null {
110
+ if (!Array.isArray(wire)) return 'invalid tree'
111
+ const boards = new Set<string>(), folders = new Set<string>()
112
+ const board = (n: unknown): string | null => {
113
+ if (!isBoardName(n) || n === 'all-scenes') return 'invalid board name in tree'
114
+ if (boards.has(n)) return `board "${n}" appears twice`
115
+ boards.add(n)
116
+ return null
117
+ }
118
+ for (const w of wire) {
119
+ if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
120
+ 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 }
122
+ if (!isBoardName(folder)) return 'invalid folder name in tree'
123
+ if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
124
+ if (folders.has(folder)) return `folder "${folder}" appears twice`
125
+ folders.add(folder)
126
+ if (!Array.isArray(kids)) return 'invalid folder in tree'
127
+ for (const k of kids) { const e = board(k); if (e) return e }
128
+ }
129
+ if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
130
+ return null
131
+ }
132
+
133
+ /** What the human types becomes a slug: "Old stuff" → "old-stuff". Empty when nothing
134
+ * survives - the caller keeps the input open and says so. Always on-grammar or empty. */
135
+ export function slugify(raw: string): string {
136
+ const s = raw.trim().toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '').slice(0, NAME_MAX).replace(/-+$/g, '')
137
+ return isBoardName(s) ? s : ''
138
+ }
139
+
140
+ // ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
141
+
142
+ export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, boards: [...it.boards] })) // a folder's description rides along
143
+ export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => t.findIndex((it) => it.kind === kind && it.name === name)
144
+ export const folderIn = (t: TreeItem[], name: string): Folder | undefined => t.find((it): it is Folder => it.kind === 'folder' && it.name === name)
145
+ export const folderOf = (t: TreeItem[], board: string): string | null => t.find((it): it is Folder => it.kind === 'folder' && it.boards.includes(board))?.name ?? null
146
+ export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
147
+ export const foldersIn = (t: TreeItem[]): string[] => t.filter((it) => it.kind === 'folder').map((it) => it.name)
148
+
149
+ /** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
150
+ export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
151
+ const ri = rootIndex(t, 'board', board)
152
+ if (ri >= 0) { t.splice(ri, 1); return { list: null, index: ri } }
153
+ for (const it of t) {
154
+ if (it.kind !== 'folder') continue
155
+ const i = it.boards.indexOf(board)
156
+ if (i >= 0) { it.boards.splice(i, 1); return { list: it.name, index: i } }
157
+ }
158
+ return null
159
+ }
160
+
161
+ /** What is being dragged, and where it may land: a slot in a list (root = null) or the
162
+ * inside of a folder (appended at its end). */
163
+ export type Drag = { kind: TreeItem['kind']; name: string }
164
+ export type Drop = { list: string | null; index: number } | { into: string }
165
+
166
+ /** The row under the pointer, measured by the DOM and handed here as plain facts. */
167
+ export interface Hit {
168
+ kind: TreeItem['kind']; name: string
169
+ parent: string | null // the folder a board row lives in
170
+ below: boolean // pointer in the lower half of the row
171
+ topEdge: boolean // pointer in the top 30% of the row (folder rows: "before me")
172
+ gutter: boolean // pointer left of a child row's indent (the root gutter = outdent)
173
+ }
174
+
175
+ /** The drop target for a drag over a row, or null. Boards land in any slot - root or inside
176
+ * a folder - or INTO a folder; folders land in root slots only. Over a folder's child row the
177
+ * root gutter means "after that folder" (the natural outdent). `all-scenes` = root end. */
178
+ export function resolveDrop(t: TreeItem[], d: Drag, hit: Hit): Drop | null {
179
+ if (hit.kind === 'folder') {
180
+ const fi = rootIndex(t, 'folder', hit.name)
181
+ if (fi < 0) return null
182
+ if (d.kind === 'folder') return { list: null, index: hit.below ? fi + 1 : fi }
183
+ return hit.topEdge ? { list: null, index: fi } : { into: hit.name } // top edge = before; the rest = inside
184
+ }
185
+ if (hit.name === 'all-scenes') return { list: null, index: t.length } // over the pinned last row = root end slot
186
+ if (hit.parent) {
187
+ const pi = rootIndex(t, 'folder', hit.parent)
188
+ if (pi < 0) return null
189
+ if (d.kind === 'folder' || hit.gutter) return { list: null, index: pi + 1 } // after that folder
190
+ const i = folderIn(t, hit.parent)?.boards.indexOf(hit.name) ?? -1
191
+ return i < 0 ? null : { list: hit.parent, index: hit.below ? i + 1 : i }
192
+ }
193
+ const i = rootIndex(t, 'board', hit.name)
194
+ return i < 0 ? null : { list: null, index: hit.below ? i + 1 : i }
195
+ }
196
+
197
+ /** A target that would leave the item where it is: nothing to show, nothing to drop. Into
198
+ * the folder a board already sits in means "to its end" - a no-op only when it is last. */
199
+ export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
200
+ if ('into' in target) {
201
+ if (d.kind !== 'board') return true
202
+ const f = folderIn(t, target.into)
203
+ return !!f && f.boards[f.boards.length - 1] === d.name
204
+ }
205
+ let from = -1
206
+ if (d.kind === 'folder') from = target.list === null ? rootIndex(t, 'folder', d.name) : -1
207
+ else if (target.list === null) from = rootIndex(t, 'board', d.name)
208
+ else from = folderIn(t, target.list)?.boards.indexOf(d.name) ?? -1
209
+ return from >= 0 && (target.index === from || target.index === from + 1)
210
+ }
211
+
212
+ /** The tree after a drop, or null when the drop is impossible on this tree. */
213
+ export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
214
+ const next = cloneTree(tree)
215
+ if (d.kind === 'folder') {
216
+ if ('into' in target || target.list !== null) return null
217
+ const from = rootIndex(next, 'folder', d.name)
218
+ if (from < 0) return null
219
+ const [it] = next.splice(from, 1)
220
+ next.splice(target.index > from ? target.index - 1 : target.index, 0, it!) // removing `from` shifts later slots left
221
+ return next
222
+ }
223
+ const src = takeBoard(next, d.name)
224
+ if (!src) return null
225
+ if ('into' in target) {
226
+ const f = folderIn(next, target.into)
227
+ if (!f) return null
228
+ f.boards.push(d.name)
229
+ return next
230
+ }
231
+ const to = src.list === target.list && target.index > src.index ? target.index - 1 : target.index
232
+ if (target.list === null) { next.splice(Math.min(to, next.length), 0, { kind: 'board', name: d.name }); return next }
233
+ const f = folderIn(next, target.list)
234
+ if (!f) return null
235
+ f.boards.splice(Math.min(to, f.boards.length), 0, d.name)
236
+ return next
237
+ }
238
+
239
+ /** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
240
+ export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
241
+ const next = cloneTree(tree)
242
+ if (!takeBoard(next, board)) return null
243
+ if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
244
+ const f = folderIn(next, folder)
245
+ if (!f) return null
246
+ f.boards.push(board)
247
+ return next
248
+ }
249
+
250
+ /** 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 {
252
+ if (foldersIn(tree).includes(name)) return null
253
+ const next = cloneTree(tree)
254
+ const boards: string[] = []
255
+ if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
256
+ next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards })
257
+ return next
258
+ }
259
+
260
+ export function renameFolder(tree: TreeItem[], from: string, to: string): TreeItem[] | null {
261
+ if (from === to) return cloneTree(tree)
262
+ if (foldersIn(tree).includes(to)) return null
263
+ const next = cloneTree(tree)
264
+ const f = folderIn(next, from)
265
+ if (!f) return null
266
+ f.name = to
267
+ return next
268
+ }
269
+
270
+ /** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
271
+ export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
272
+ const next = cloneTree(tree)
273
+ const i = rootIndex(next, 'folder', name)
274
+ if (i < 0) return null
275
+ const f = next[i] as Folder
276
+ next.splice(i, 1, ...f.boards.map((b): TreeItem => ({ kind: 'board', name: b })))
277
+ return next
278
+ }
279
+
280
+ /** Where "Move to new folder" puts the folder: the board's own root slot, or right after the
281
+ * folder it sits in. */
282
+ export const newFolderSlot = (tree: TreeItem[], board: string): number => {
283
+ const parent = folderOf(tree, board)
284
+ return parent ? rootIndex(tree, 'folder', parent) + 1 : Math.max(0, rootIndex(tree, 'board', board))
285
+ }