@marver-design/marver 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -15
- package/README.md +17 -4
- package/dist/{bake-BID6mo-N.mjs → bake-BSX4XR_U.mjs} +1 -1
- package/dist/board-status-CWGHIdo_.mjs +1307 -0
- package/dist/boards-BTGNPVMx.mjs +187 -0
- package/dist/{build-C7MqQ7hq.mjs → build-Dd2Qm3OG.mjs} +74 -13
- package/dist/cli.mjs +67 -8
- package/dist/config-DJxMRVD8.mjs +373 -0
- package/dist/context-DUAENlJ6.mjs +600 -0
- package/dist/{daemon-CRZFpl6K.mjs → daemon-BmkwErpC.mjs} +1 -1
- package/dist/{dev-BNZF4Mup.mjs → dev-BW7a5kDF.mjs} +8 -6
- package/dist/{init-C34BY3R4.mjs → init-NJ3xjLVu.mjs} +39 -44
- package/dist/managed-write-Bo-oPc-i.mjs +71 -0
- package/dist/{manifest-mMfUhPtL.mjs → manifest-BqBcMJcd.mjs} +19 -376
- package/dist/{plugin-omHLCn91.mjs → plugin-8XntSCx_.mjs} +234 -105
- package/dist/{poster-BvxiAzy1.mjs → poster-DRZDszTI.mjs} +1 -1
- package/dist/{publish-bakes-BqzAAa3w.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
- package/dist/{shot-DswS4iRK.mjs → shot-C22Ues04.mjs} +2 -2
- package/docs/boards-and-folders.md +161 -0
- package/docs/context.md +117 -0
- package/docs/sharing.md +12 -2
- package/package.json +1 -1
- package/src/client/shell/BoardList.tsx +33 -3
- package/src/client/shell/ContextMenu.tsx +16 -5
- package/src/client/shell/StatusPicker.tsx +91 -0
- package/src/client/shell/board-icons.tsx +75 -0
- package/src/client/shell/store.ts +88 -9
- package/src/client/shell/styles.css +34 -3
- package/src/shared/board-tree.ts +30 -13
- package/src/shared/board-types.ts +103 -0
- package/src/shared/context.ts +265 -0
- package/src/shared/status.ts +157 -0
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/context/INDEX.md +50 -0
- package/templates/context/map.json +6 -0
- package/templates/context/shipped-knowledge.md +19 -0
- package/templates/context/shipped.md +28 -0
- package/templates/instructions/boards.md +31 -0
- package/templates/instructions/context.md +114 -0
- package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
- package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
- package/templates/playbooks/reorganize-context/eval.md +93 -0
- package/dist/boards-BwiDAmPf.mjs +0 -337
- 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
|
-
|
|
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
|
|
778
|
+
const send = (base: string | null | undefined) => postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) })
|
|
745
779
|
let res: Response
|
|
746
|
-
try {
|
|
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
|
-
|
|
512
|
-
|
|
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,
|
|
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 */
|
package/src/shared/board-tree.ts
CHANGED
|
@@ -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
|
|
54
|
-
const folderExtras = (it: { title?: string; description?: string }) => ({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|