@marver-design/marver 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +134 -15
  2. package/README.md +17 -4
  3. package/dist/{bake-BID6mo-N.mjs → bake-CvY8QR3L.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BwKI0qWd.mjs +188 -0
  6. package/dist/{build-C7MqQ7hq.mjs → build-B1kavcpc.mjs} +74 -13
  7. package/dist/cli.mjs +69 -9
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-D4t59wDi.mjs +600 -0
  10. package/dist/{daemon-CRZFpl6K.mjs → daemon-PmNqoOAk.mjs} +1 -1
  11. package/dist/{dev-BNZF4Mup.mjs → dev-Cf4wLGe2.mjs} +15 -7
  12. package/dist/{init-C34BY3R4.mjs → init-C_K-cjBQ.mjs} +41 -45
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-mMfUhPtL.mjs → manifest-CcdWx7ud.mjs} +42 -376
  15. package/dist/{plugin-omHLCn91.mjs → plugin-C5u07Yhv.mjs} +234 -105
  16. package/dist/{poster-BvxiAzy1.mjs → poster-BduzQBYz.mjs} +1 -1
  17. package/dist/{publish-bakes-BqzAAa3w.mjs → publish-bakes-b0jUwvM_.mjs} +2 -2
  18. package/dist/{shot-DswS4iRK.mjs → shot-BAR8hmU9.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 +33 -3
  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 +88 -9
  28. package/src/client/shell/styles.css +34 -3
  29. package/src/shared/board-tree.ts +30 -13
  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 +12 -0
  34. package/templates/AGENTS-studio.md +12 -0
  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 +33 -0
  40. package/templates/instructions/context.md +123 -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-BwiDAmPf.mjs +0 -337
  45. package/dist/boards-DnLewfj8.mjs +0 -71
@@ -17,6 +17,10 @@ const DATA: {
17
17
  tree?: TreeItem[]
18
18
  /** slug → title of the published boards (0.16.1 bundles); folder titles ride on `tree` */
19
19
  titles?: Record<string, string>
20
+ /** slug → resolved type, and the status a publish row opted into (0.22 bundles; spec 20) */
21
+ meta?: Record<string, BoardMeta>
22
+ /** when the build read the statuses it ships - a published status is a snapshot */
23
+ statusAsOf?: string
20
24
  /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
21
25
  policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
22
26
  /** the generation of the glass textures this build shipped (publish-bakes.ts); absent = none */
@@ -25,6 +29,8 @@ const DATA: {
25
29
 
26
30
  /** The published textures' generation, or 0: the static index this build shipped is at /__mv/bakes/<gen>/index.json. */
27
31
  export const BAKES = DATA?.bakes ?? 0
32
+ /** When a published bundle's statuses were read (spec 20) - absent in dev, where they are live. */
33
+ export const STATUS_AS_OF = DATA?.statusAsOf ?? null
28
34
 
29
35
  /** Every published board is locked to a stage mode - the canvas shell is never
30
36
  * offered on this bundle (01-sharing §5.1's all-boards rule). */
@@ -139,20 +145,32 @@ export { cap, humanize } from './labels.ts'
139
145
  import { cap, humanize } from './labels.ts'
140
146
  import { canAutoReload } from './canvas/ready-watch.ts'
141
147
  import { buildTree, flatten, labelOf, toWire, TREE_PROTOCOL, type TreeItem } from '../../shared/board-tree.ts'
148
+ import type { BoardType, StatusWord } from '../../shared/board-types.ts'
149
+ import type { Phase, Status } from '../../shared/status.ts'
142
150
 
143
151
  /** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
144
152
  * the folder registry (null = there was no file). */
145
153
  export interface TreeBase { boards: Record<string, string>; folders: string | null }
146
- export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
154
+ export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string>; meta: Record<string, BoardMeta> }
155
+ /** What spec 20 adds to a board's row: its resolved type, and - for feature and project boards -
156
+ * its status read from context/. Dev carries the evidence for the tooltip; a published bundle
157
+ * carries only what its publish row opted into. */
158
+ export interface BoardMeta {
159
+ type?: BoardType
160
+ /** `row` is the table row that decided it (1-3: a decision on the board) */
161
+ status?: { status: Status; row?: number; fill?: Phase; reason?: string; evidence?: string[] }
162
+ /** what a person may set it to from the sidebar (dev only) - shared/board-types settableStatuses */
163
+ settable?: StatusWord[]
164
+ }
147
165
 
148
166
  /** The sidebar tree: root boards and folders in rank order, each folder's boards and
149
167
  * sub-folders inside (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
150
168
  * is pinned last by the callers. Throws on transport failure and on a malformed registry
151
169
  * (the server's 422 message) - callers keep their last known tree. */
152
170
  export async function fetchBoardTree(): Promise<TreeSnapshot> {
153
- 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 ?? {} }
171
+ 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 ?? {}, meta: DATA.meta ?? {} }
154
172
  const [boards, reg] = await Promise.all([
155
- fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
173
+ fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string; type?: BoardType; status?: BoardMeta['status'] | null; settable?: StatusWord[] }[]>,
156
174
  fetch(`${ROUTE}/api/folders`).then(async (r) => {
157
175
  const j = await r.json() as { folders?: { name: string; order?: number; parent?: string; title?: string }[]; sha256?: string | null; error?: string }
158
176
  if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
@@ -163,8 +181,13 @@ export async function fetchBoardTree(): Promise<TreeSnapshot> {
163
181
  tree: buildTree(boards, reg.folders ?? []),
164
182
  base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
165
183
  titles: Object.fromEntries(boards.flatMap((b) => (b.title ? [[b.name, b.title]] : []))),
184
+ meta: Object.fromEntries(boards.map((b) => [b.name, { ...(b.type && b.type !== 'plain' ? { type: b.type } : {}), ...(b.status ? { status: b.status } : {}), ...(b.settable?.length ? { settable: b.settable } : {}) }])),
166
185
  }
167
186
  }
187
+ /** A tree read's types and statuses, kept under the same latest-wins rule as its titles. */
188
+ export function rememberMeta(meta: Record<string, BoardMeta>) {
189
+ if (JSON.stringify(useStore.getState().boardMeta) !== JSON.stringify(meta)) useStore.setState({ boardMeta: meta })
190
+ }
168
191
  /** A tree read's board titles become the labels' - called by the reader once it knows the
169
192
  * read is its latest (an older response must not overwrite a newer one's labels). Folder
170
193
  * titles ride on the tree's folder items: a board and a folder may share a slug, never one map. */
@@ -179,6 +202,7 @@ export async function fetchBoardNames(): Promise<string[]> {
179
202
  if (DATA) return DATA.names
180
203
  const snap = await fetchBoardTree()
181
204
  rememberTitles(snap.titles)
205
+ rememberMeta(snap.meta)
182
206
  return [...flatten(snap.tree), 'all-scenes']
183
207
  }
184
208
  /** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
@@ -322,7 +346,9 @@ interface State {
322
346
  imagePulse: number // bumps on each successful image copy - same flash, the images-square icon
323
347
  imageBusy: boolean // a copy-as-image render is in flight (one at a time)
324
348
 
325
- boot(): Promise<boolean>
349
+ /** (re)load the open board from disk. `mayCommit`, when given, is asked again at the moment the
350
+ * reload would land - a caller's own conditions (a status write's: nothing moved since its flush) */
351
+ boot(mayCommit?: () => boolean): Promise<boolean>
326
352
  applyManifest(m: Manifest): void
327
353
  frameFor(node: Node): FrameEntry | undefined
328
354
  moveNode(key: string, x: number, y: number): void
@@ -353,10 +379,16 @@ interface State {
353
379
  switchBoard(name: string): Promise<void>
354
380
  /** what the sidebar labels boards by: slug → title, off the last tree read */
355
381
  boardTitles: Record<string, string>
382
+ /** what the sidebar draws beside a board: its type and status (spec 20), off the last tree read */
383
+ boardMeta: Record<string, BoardMeta>
356
384
  /** retitle a board - what humans see ('' = back to the Title-Cased slug). The file never
357
385
  * moves: its name is the board's identity (agents, publish.json, URLs, comment threads).
358
386
  * `baseHash` = the file as last seen; a 409 (`stale`) means someone wrote it since. */
359
387
  renameBoard(name: string, title: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
388
+ /** a board's status, decided by a person (the sidebar's picker): one of its `settable` statuses,
389
+ * or null to clear the decision (back to the evidence); blocked carries its reason. Only
390
+ * `status` and `reason` change in the file; a 409 (`stale`) means someone wrote it since. */
391
+ setBoardStatus(name: string, status: StatusWord | null, reason?: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
360
392
  /** a scene's title, into its brief's front matter (the directory never moves) */
361
393
  renameScene(scene: string, title: string): Promise<{ ok: boolean; error?: string }>
362
394
  /** `sh:scenes`: the scenes changed (a brief's title or description) with the frames intact */
@@ -669,10 +701,10 @@ export const useStore = create<State>((set, get) => {
669
701
  return {
670
702
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
671
703
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
672
- panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {},
704
+ panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {}, boardMeta: DATA?.meta ?? {},
673
705
  pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
674
706
 
675
- async boot() {
707
+ async boot(mayCommit) {
676
708
  const seq = ++loadSeq
677
709
  const boardName = get().board
678
710
  const revAtStart = editRev, scenesAtStart = scenesRev
@@ -680,7 +712,7 @@ export const useStore = create<State>((set, get) => {
680
712
  if (seq !== loadSeq) return false // a newer load superseded this one
681
713
  if (!next) { get().toast(`board "${boardName}" failed to load`); return false }
682
714
  // the user kept editing while we fetched - their newer state wins over the reload
683
- if (get().board !== boardName || editRev !== revAtStart) return false
715
+ if (get().board !== boardName || editRev !== revAtStart || (mayCommit && !mayCommit())) return false
684
716
  const live = get().manifest // a WS manifest update may have landed mid-fetch
685
717
  set(next)
686
718
  if (next.dirty) scheduleSave() // load-time prune must reach the disk
@@ -738,12 +770,18 @@ export const useStore = create<State>((set, get) => {
738
770
  // hash the rewrite is about to move
739
771
  holdSaves()
740
772
  }
773
+ const rev = editRev // setBoardStatus's guard on the reload
774
+ const mayReload = () => active && get().board === from && editRev === rev && !get().gesture
741
775
  try {
742
776
  // the active board's hash is freshest in the store (an autosave may have landed since
743
777
  // the sidebar looked); every other board's is the sidebar's
744
- const base = active && get().boardHash ? get().boardHash : baseHash
778
+ const send = (base: string | null | undefined) => postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) })
745
779
  let res: Response
746
- try { res = await postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) }) }
780
+ try {
781
+ res = await send(active && get().boardHash ? get().boardHash : baseHash)
782
+ // the open board changed on disk: reload it, then once more (setBoardStatus's reasoning)
783
+ if (res.status === 409 && mayReload() && await get().boot(mayReload)) res = await send(get().boardHash)
784
+ }
747
785
  catch { return { ok: false, error: 'could not reach the dev server' } }
748
786
  const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
749
787
  if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `rename failed (${res.status})` }
@@ -758,6 +796,47 @@ export const useStore = create<State>((set, get) => {
758
796
  })
759
797
  },
760
798
 
799
+ setBoardStatus(name, status, reason, baseHash) {
800
+ return structural(async () => {
801
+ const active = name === get().board
802
+ // the active board's file is rewritten under the autosave: flush first, hold across (renameBoard's pattern)
803
+ if (active) {
804
+ let ok = true
805
+ for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
806
+ if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
807
+ holdSaves()
808
+ }
809
+ // what a reload may replace: this board as flushed - holdSaves pauses saving, not editing, so an
810
+ // edit, a drag or a board switch while the request is out means the reload is not ours to do
811
+ const rev = editRev
812
+ const mayReload = () => active && get().board === name && editRev === rev && !get().gesture
813
+ try {
814
+ const send = (base: string | null | undefined) => postOwner('boards/status', { name, status, ...(reason ? { reason } : {}), ...(base ? { baseHash: base } : {}) })
815
+ let res: Response
816
+ try {
817
+ res = await send(active && get().boardHash ? get().boardHash : baseHash)
818
+ // the open board changed on disk (an agent wrote it): its hash in the store is behind, and a
819
+ // retry with it would 409 again - reload the board (layout and hash together) when nothing
820
+ // has changed here since the flush, then try once more against what is on disk now
821
+ if (res.status === 409 && mayReload() && await get().boot(mayReload)) res = await send(get().boardHash)
822
+ }
823
+ catch { return { ok: false, error: 'could not reach the dev server' } }
824
+ const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
825
+ if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `status failed (${res.status})` }
826
+ if (active && get().board === name && body?.sha256) set({ boardHash: body.sha256 })
827
+ // a decision shows at once; what the evidence says after a clear comes with the next tree read
828
+ if (status) {
829
+ const meta = { ...get().boardMeta }
830
+ const cur = meta[name] ?? {}
831
+ const row = ({ archived: 1, paused: 2, blocked: 3, 'in-progress': 5, done: 6, todo: 8, backlog: 9 } as const)[status]
832
+ meta[name] = { ...cur, status: { status, row, ...(status === 'blocked' && reason ? { reason } : {}), evidence: [`design/boards/${name}.json: "status": "${status}"`] } }
833
+ set({ boardMeta: meta })
834
+ }
835
+ return { ok: true }
836
+ } finally { if (active) releaseSaves() }
837
+ })
838
+ },
839
+
761
840
  async renameScene(scene, title) {
762
841
  let res: Response
763
842
  try { res = await postOwner('scenes/rename', { scene, title }) }
@@ -469,6 +469,15 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
469
469
  .sh-panel .it.board svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
470
470
  .sh-panel .it.board.cur { background: var(--accent-wash); color: var(--glass-accent); font-weight: 600 }
471
471
  .sh-panel .it.board.cur svg { color: var(--glass-accent) }
472
+ /* spec 20: a feature or project board's status ring sits at the row's end, in its own colours -
473
+ never the accent of the current row - with its evidence in the tooltip */
474
+ .sh-panel .it.board .st { flex: none; display: inline-flex; margin-left: auto; font-style: normal }
475
+ /* the settled statuses' fills, one value per theme: done is the content palette's green (Apple's
476
+ systemGreen); archived is Apple's brown - its increased-contrast value on light, its default on
477
+ dark, where the increased-contrast one turns peach - so it reads as the same brown box in both */
478
+ .sh-app { --status-done: #34c759; --status-archived: #956d51 }
479
+ .sh-app.dark { --status-done: #30d158; --status-archived: #b78a66 }
480
+ .sh-panel .it.board .st svg, .sh-panel .it.board.cur .st svg { color: inherit; margin: 0 }
472
481
  /* drag-to-reorder: a grab cursor, the lifted row dims, and a 2px accent rule marks the
473
482
  drop seam above or below the hovered row */
474
483
  /* a board row is click-to-switch first (pointer on hover); the grabbing hand appears the
@@ -508,14 +517,16 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
508
517
  .sh-panel .it.folder.drop-after::after { bottom: -1px }
509
518
  .sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
510
519
  .sh-panel .it.folder.drop-into svg { color: #0088ff }
511
- .sh-panel .it.board.in-folder, .sh-panel .it.folder.in-folder { padding-left: 28px }
512
- .sh-panel .it.board.in-sub { padding-left: 56px }
520
+ /* 8px row padding + INDENT (22px, board-tree.ts) per level: a nested row's icon starts exactly
521
+ where its folder's name starts, at every level */
522
+ .sh-panel .it.board.in-folder, .sh-panel .it.folder.in-folder { padding-left: 30px }
523
+ .sh-panel .it.board.in-sub { padding-left: 52px }
513
524
  .sh-panel .it.board.draft { cursor: default; opacity: .6 }
514
525
  /* seams are indented with the list they land in (--seam-left, set on the row that draws them),
515
526
  so "inside", "inside the sub-folder" and "at the root" never draw alike; an open EMPTY folder
516
527
  draws that indented seam under its own header (drop-in) */
517
528
  .sh-panel .it.folder.drop-in { position: relative }
518
- .sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: var(--seam-left, 28px); right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
529
+ .sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: var(--seam-left, 30px); right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
519
530
  background: #0088ff; pointer-events: none }
520
531
  /* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
521
532
  .sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
@@ -588,6 +599,26 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
588
599
  .sh-menu .chk { color: var(--glass-accent) }
589
600
  .sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
590
601
  .sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
602
+ /* the board status picker (StatusPicker.tsx): opens in place of the right-click menu - a filter,
603
+ what the evidence says now, the statuses a person may set, numbered, and a line on the rest */
604
+ .sh-ctxpanel { width: 248px; max-width: 248px }
605
+ .sh-status-picker { display: flex; flex-direction: column }
606
+ .sh-status-picker .sp-input { height: 32px; margin: 0 0 4px; padding: 0 10px; border: 0; border-bottom: 1px solid var(--glass-brd);
607
+ border-radius: 0; background: none; color: var(--glass-ink); font: 500 12.5px -apple-system, system-ui, sans-serif; outline: 0 }
608
+ .sh-status-picker .sp-input::placeholder { color: var(--glass-ink-3) }
609
+ .sh-status-picker .sp-now { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; margin-bottom: 2px;
610
+ font: 500 12px -apple-system, system-ui, sans-serif; color: var(--glass-ink) }
611
+ .sh-status-picker .sp-now small { margin-left: auto; color: var(--glass-ink-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px }
612
+ .sh-status-picker .sp-list { display: flex; flex-direction: column }
613
+ .sh-status-picker .sp-list button { width: 100% }
614
+ .sh-status-picker .sp-list button.hi { background: var(--glass-hover) }
615
+ .sh-status-picker .sp-list button span { text-transform: none }
616
+ .sh-status-picker .sp-chk { color: var(--glass-ink); flex: none }
617
+ .sh-status-picker .sp-head { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; font: 600 12px -apple-system, system-ui, sans-serif; color: var(--glass-ink) }
618
+ .sh-status-picker .sp-empty { padding: 8px 10px; color: var(--glass-ink-3); font: 500 12px -apple-system, system-ui, sans-serif }
619
+ .sh-status-picker .sp-foot { border-top: 1px solid var(--glass-brd); margin-top: 4px; padding: 7px 10px 3px; color: var(--glass-ink-3);
620
+ font: 500 11px/1.35 -apple-system, system-ui, sans-serif }
621
+ .sh-status-picker[data-status-picker="reason"] .sp-input { border: 1px solid var(--glass-brd); border-radius: 9px; margin: 2px 6px 0; height: 30px }
591
622
 
592
623
  /* shadcn-style tooltip: contrast-flipped solid, zoom-fade in */
593
624
  .sh-tip { position: absolute; z-index: 400; pointer-events: none; padding: 5px 10px; border-radius: 8px; /* topmost ephemeral chrome - above modals (300) or in-dialog tips vanish */
@@ -8,6 +8,8 @@
8
8
  * tree - callers pin it last.
9
9
  */
10
10
 
11
+ import { readType } from './board-types.ts'
12
+
11
13
  /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
12
14
  export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
13
15
  export const NAME_MAX = 64
@@ -20,11 +22,11 @@ export const FOLDERS_FILE = '_folders.json'
20
22
  export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
21
23
 
22
24
  /** A folder and what it holds, in order: boards, and - in a top-level folder only - sub-folders. */
23
- export type Folder = { kind: 'folder'; name: string; items: TreeItem[]; title?: string; description?: string }
25
+ export type Folder = { kind: 'folder'; name: string; items: TreeItem[]; title?: string; description?: string; type?: string }
24
26
  export type TreeItem = { kind: 'board'; name: string } | Folder
25
27
 
26
28
  export interface BoardRow { name: string; order?: number; folder?: string; title?: string }
27
- export interface FolderRow { name: string; order?: number; parent?: string; title?: string; description?: string }
29
+ export interface FolderRow { name: string; order?: number; parent?: string; title?: string; description?: string; type?: string }
28
30
 
29
31
  /** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
30
32
  export const DESCRIPTION_MAX = 300
@@ -50,8 +52,12 @@ export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|
50
52
  export const labelOf = (name: string, title?: string): string => title ?? humanize(name)
51
53
 
52
54
  const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
53
- /** A folder's title and description, present only when set. */
54
- 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
+ })
55
61
 
56
62
  /** The registry versions this code reads. Version 2 is written only when a folder has a
57
63
  * `parent`, so a flat registry stays readable by every Marver; an older Marver refuses a
@@ -85,7 +91,8 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
85
91
  if (p !== undefined && !isBoardName(p)) return `folder "${name}" has an invalid parent - a folder name`
86
92
  const t = readTitle((f as { title?: unknown }).title)
87
93
  const d = readDescription((f as { description?: unknown }).description)
88
- out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(p !== undefined ? { parent: p } : {}), ...(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 } : {}) })
89
96
  }
90
97
  const byName = new Map(out.map((f) => [f.name, f]))
91
98
  for (const f of out) {
@@ -104,7 +111,7 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
104
111
  * after ranked. The root holds root boards and top-level folders (registered without a
105
112
  * parent, or implied by a board that names an unregistered folder); a top-level folder holds
106
113
  * its boards and its sub-folders; a sub-folder holds its boards. A folder's title and
107
- * description ride on its item (they live in the registry a tree write rewrites); a board's
114
+ * description and type ride on its item (they live in the registry a tree write rewrites); a board's
108
115
  * title stays with its row - it lives in the board's own file. */
109
116
  export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
110
117
  const reg = new Map<string, FolderRow>()
@@ -152,12 +159,12 @@ export function flatten(tree: TreeItem[]): string[] {
152
159
 
153
160
  /** The wire shape of a tree write (`POST boards/reorder`): plain strings for boards,
154
161
  * `{ folder, items }` for folders, `items` holding boards and - one level down - folders.
155
- * What the sidebar posts and the server validates. A folder's title and description ride
162
+ * What the sidebar posts and the server validates. A folder's title, description and type ride
156
163
  * with it (they live in the registry the write rewrites); a board's title lives in its own
157
164
  * file and never rides the tree. A one-level `{ folder, boards }` item (an older shell) is
158
165
  * still read. */
159
- export type WireItem = string | { folder: string; items: WireItem[]; title?: string; description?: string }
160
- type WireIn = string | { folder: string; items?: WireIn[]; boards?: string[]; title?: string; description?: string }
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 }
161
168
  export const toWire = (tree: TreeItem[]): WireItem[] =>
162
169
  tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, items: toWire(it.items), ...folderExtras(it) }))
163
170
  export const wireKids = (w: Exclude<WireIn, string>): WireIn[] => w.items ?? w.boards ?? []
@@ -183,10 +190,11 @@ export function validateWire(wire: unknown): string | null {
183
190
  if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
184
191
  if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
185
192
  if (depth >= 2) return 'folders nest one level only'
186
- const { folder, items, boards: legacy, title, description } = w as { folder?: unknown; items?: unknown; boards?: unknown; title?: unknown; description?: unknown }
193
+ const { folder, items, boards: legacy, title, description, type } = w as { folder?: unknown; items?: unknown; boards?: unknown; title?: unknown; description?: unknown; type?: unknown }
187
194
  if (!isBoardName(folder)) return 'invalid folder name in tree'
188
195
  if (title !== undefined && (typeof title !== 'string' || Array.from(title).length > TITLE_MAX)) return 'invalid folder title'
189
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'
190
198
  if (folders.has(folder)) return `folder "${folder}" appears twice`
191
199
  folders.add(folder)
192
200
  const kids = items ?? legacy
@@ -213,7 +221,7 @@ export function slugify(raw: string): string {
213
221
  // ---- reading the tree ----
214
222
 
215
223
  /** A deep copy: mutations work on it and the caller's tree stays as it was (a folder's
216
- * title and description ride along). */
224
+ * title, description and type ride along). */
217
225
  export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, items: cloneTree(it.items) }))
218
226
  /** Every folder with the folder it sits in (null = the root), top-level folders first in
219
227
  * reading order, each followed by its sub-folders. */
@@ -230,6 +238,12 @@ export function folderEntries(t: TreeItem[]): { folder: Folder; parent: string |
230
238
  export const folderIn = (t: TreeItem[], name: string): Folder | undefined => folderEntries(t).find((e) => e.folder.name === name)?.folder
231
239
  /** The folder a folder sits in - null for a top-level folder (or one that is not there). */
232
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
+ }
233
247
  /** The folder a board sits in directly, at either level - null at the root. */
234
248
  export function folderOf(t: TreeItem[], board: string): string | null {
235
249
  for (const { folder } of folderEntries(t)) if (folder.items.some((k) => k.kind === 'board' && k.name === board)) return folder.name
@@ -278,8 +292,11 @@ export type Drop = { list: string | null; index: number } | { into: string }
278
292
  * folder's items, 2 a sub-folder's). `open` is a folder's disclosure; `left` is the row's
279
293
  * left edge, from which the indent of each level is measured. */
280
294
  export interface Row { kind: TreeItem['kind']; name: string; parent: string | null; depth: number; open?: boolean; top: number; bottom: number; left: number }
281
- /** Px each level of nesting indents its rows; the seams inside draw from there too. */
282
- export const INDENT = 28
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
283
300
 
284
301
  /** How deep the dragged item may land: a board anywhere (2), a folder without sub-folders
285
302
  * inside a top-level folder (1), a folder holding sub-folders at the root only (0). */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Board types (spec 20) - what a board is FOR in the workspace, one vocabulary every canvas
3
+ * shares so any sidebar reads at a glance. A board states its own `"type"`, or wears the type of
4
+ * the nearest typed folder above it (its folder, then that folder's parent), else it is plain.
5
+ * Moving a board changes an inherited type, never one the board states itself.
6
+ *
7
+ * Pure and shared: the dev API, the manifest, the build, the shell and the CLI all read types
8
+ * through here, so a type means one thing everywhere.
9
+ */
10
+
11
+ export const BOARD_TYPES = ['start', 'feature', 'surface', 'project', 'feedback', 'context', 'deck', 'archive'] as const
12
+ export type KnownType = (typeof BOARD_TYPES)[number]
13
+ export type BoardType = KnownType | 'plain'
14
+
15
+ /** What each type is for - the sidebar's tooltip, the CLI's help, the docs. */
16
+ export const TYPE_ROLES: Record<BoardType, string> = {
17
+ start: 'the way in: the index, the shipped record, the timeline',
18
+ feature: 'one capability: its spec, lo-fi and hi-fi',
19
+ surface: 'the whole product to walk, from frames on feature boards',
20
+ project: 'a deliverable or a question',
21
+ feedback: 'one frame per theme',
22
+ context: 'what came in from outside: meetings, threads, competitors',
23
+ deck: 'slides',
24
+ archive: 'snapshots and retired work',
25
+ plain: 'a board',
26
+ }
27
+
28
+ /** The grammar a stored type keeps to. Any on-grammar word survives a write - a newer Marver's
29
+ * type is never dropped by an older shell - while only the known ones are drawn. */
30
+ export const TYPE_GRAMMAR = /^[a-z][a-z-]{0,31}$/
31
+ /** A type off a file: the word when it is on-grammar, else absent. */
32
+ export const readType = (v: unknown): string | undefined => (typeof v === 'string' && TYPE_GRAMMAR.test(v) ? v : undefined)
33
+ /** A type this Marver draws. */
34
+ export const knownType = (v: unknown): KnownType | undefined =>
35
+ (BOARD_TYPES as readonly string[]).includes(v as string) ? (v as KnownType) : undefined
36
+
37
+ /** A board's type: its own when it states one (an unknown word reads as plain - the board spoke
38
+ * for itself), else its folder's, else that folder's parent's, else plain. */
39
+ export function resolveType(own: unknown, folder?: unknown, parent?: unknown): BoardType {
40
+ if (readType(own)) return knownType(own) ?? 'plain'
41
+ return knownType(folder) ?? knownType(parent) ?? 'plain'
42
+ }
43
+
44
+ /** The publish type a board's type proposes when its publish row names none - how a board
45
+ * PRESENTS when published, a separate question from what it is for. */
46
+ export const PROPOSED_PUBLISH: Partial<Record<BoardType, 'slides' | 'refs' | 'doc' | 'mix'>> = {
47
+ deck: 'slides',
48
+ context: 'refs',
49
+ project: 'doc',
50
+ feature: 'mix',
51
+ surface: 'mix',
52
+ }
53
+
54
+ /** The types that carry a status (spec 20, open question 2: features and projects - the others
55
+ * have no lifecycle of their own). */
56
+ export const HAS_STATUS: readonly BoardType[] = ['feature', 'project']
57
+
58
+ /** The decisions a board may state by hand. `done` is never one: Done comes only from the shipped
59
+ * record. `todo`, `backlog` and `in-progress` count only where there is no `context/`. */
60
+ export const DECISIONS = ['archived', 'paused', 'blocked'] as const
61
+ export const BY_HAND = ['in-progress', 'todo', 'backlog'] as const
62
+ export const STATUS_WORDS = [...DECISIONS, ...BY_HAND, 'done'] as const
63
+ export type StatusWord = (typeof STATUS_WORDS)[number]
64
+ export const readStatusWord = (v: unknown): StatusWord | undefined =>
65
+ (STATUS_WORDS as readonly string[]).includes(v as string) ? (v as StatusWord) : undefined
66
+ /** The statuses a person may set on a feature or project board, in the order a picker lists them:
67
+ * the three decisions always; Backlog, To do and In progress only where there is no `context/`
68
+ * (with it they are read from the evidence). Never Done - that is the shipped record's alone. */
69
+ export const settableStatuses = (contextPresent: boolean): StatusWord[] =>
70
+ contextPresent ? ['blocked', 'paused', 'archived'] : ['backlog', 'todo', 'in-progress', 'blocked', 'paused', 'archived']
71
+
72
+ /** A capability slug - the same grammar as a board name, so a feature board and its contract
73
+ * share it. */
74
+ export const readCapability = (v: unknown): string | undefined =>
75
+ typeof v === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(v) ? v : undefined
76
+ /** A blocked board's reason: one sentence, trimmed and capped. */
77
+ export const REASON_MAX = 300
78
+ export const readReason = (v: unknown): string | undefined => {
79
+ if (typeof v !== 'string') return undefined
80
+ const s = v.trim().replace(/\s+/g, ' ').slice(0, REASON_MAX)
81
+ return s || undefined
82
+ }
83
+
84
+ /** The sidebar every canvas shares (spec 19, The canvas): folder modules, each typed, so a board
85
+ * made inside one wears its type. `marver init --kind` creates a kind's set on a fresh canvas;
86
+ * `marver folders add <module>` adds one later. Names are slugs; only Start here needs a title. */
87
+ export const FOLDER_MODULES: Record<string, { name: string; title?: string; type: KnownType }> = {
88
+ start: { name: 'start-here', title: 'Start here', type: 'start' },
89
+ features: { name: 'features', type: 'feature' },
90
+ surfaces: { name: 'surfaces', type: 'surface' },
91
+ projects: { name: 'projects', type: 'project' },
92
+ feedback: { name: 'feedback', type: 'feedback' },
93
+ context: { name: 'context', type: 'context' },
94
+ decks: { name: 'decks', type: 'deck' },
95
+ archive: { name: 'archive', type: 'archive' },
96
+ }
97
+ /** A kind of work's folders, in sidebar order: the core (Start here, Feedback, Context, Archive)
98
+ * around the kind's own modules. Decks are an add-on for either. */
99
+ export const KIND_FOLDERS = {
100
+ product: ['start', 'features', 'surfaces', 'feedback', 'context', 'archive'],
101
+ knowledge: ['start', 'projects', 'feedback', 'context', 'archive'],
102
+ } as const
103
+ export type Kind = keyof typeof KIND_FOLDERS