@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +112 -15
  2. package/README.md +17 -4
  3. package/dist/{bake-BID6mo-N.mjs → bake-BSX4XR_U.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BTGNPVMx.mjs +187 -0
  6. package/dist/{build-C7MqQ7hq.mjs → build-Dd2Qm3OG.mjs} +74 -13
  7. package/dist/cli.mjs +67 -8
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-DUAENlJ6.mjs +600 -0
  10. package/dist/{daemon-CRZFpl6K.mjs → daemon-BmkwErpC.mjs} +1 -1
  11. package/dist/{dev-BNZF4Mup.mjs → dev-BW7a5kDF.mjs} +8 -6
  12. package/dist/{init-C34BY3R4.mjs → init-NJ3xjLVu.mjs} +39 -44
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-mMfUhPtL.mjs → manifest-BqBcMJcd.mjs} +19 -376
  15. package/dist/{plugin-omHLCn91.mjs → plugin-8XntSCx_.mjs} +234 -105
  16. package/dist/{poster-BvxiAzy1.mjs → poster-DRZDszTI.mjs} +1 -1
  17. package/dist/{publish-bakes-BqzAAa3w.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
  18. package/dist/{shot-DswS4iRK.mjs → shot-C22Ues04.mjs} +2 -2
  19. package/docs/boards-and-folders.md +161 -0
  20. package/docs/context.md +117 -0
  21. package/docs/sharing.md +12 -2
  22. package/package.json +1 -1
  23. package/src/client/shell/BoardList.tsx +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 +1 -0
  34. package/templates/AGENTS-studio.md +1 -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 +31 -0
  40. package/templates/instructions/context.md +114 -0
  41. package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
  42. package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
  43. package/templates/playbooks/reorganize-context/eval.md +93 -0
  44. package/dist/boards-BwiDAmPf.mjs +0 -337
  45. package/dist/boards-DnLewfj8.mjs +0 -71
@@ -0,0 +1,117 @@
1
+ # Context
2
+
3
+ Code says what is implemented. **`context/`** says the rest - what is available and to whom, how each
4
+ capability works, why it was decided, what is next - with evidence, in files any agent reads. The
5
+ canvas reads the same files: every feature board wears the status they record.
6
+
7
+ ```
8
+ context/
9
+ INDEX.md routing only, under 800 words - read first
10
+ shipped.md the only place availability is written, every cell with its evidence
11
+ product/<capability>.md one current contract per capability
12
+ map.json which code each capability lives in
13
+ playbooks/<name>/ how we do things: steps with checks, traps, a run log
14
+ feedback/ the inbox: one row per piece of feedback, with its state
15
+ plans/ proposals in flight - folded into their contract when they land
16
+ ```
17
+
18
+ A fresh agent with no memory reads the index and at most two more files per question. On the first
19
+ project this was run on, a blind eval answered the same 16 questions correctly before and after - with
20
+ 40% fewer tokens after, and answers that said which deploys were confirmed and which only reported.
21
+
22
+ ## Start
23
+
24
+ ```bash
25
+ npx marver context init
26
+ ```
27
+
28
+ Creates `INDEX.md`, `shipped.md`, `map.json` and the two playbooks Marver maintains -
29
+ `reorganize-context` and `publish-canvas` - and adds one line to your root `AGENTS.md` routing every
30
+ agent to the index. It never overwrites; re-running it updates the playbooks you have not edited and
31
+ stages a new version beside any you have. `--kind knowledge` keeps a delivered record instead -
32
+ Project and Delivered columns - and a repository without a canvas gets the conventions themselves as
33
+ `context/README.md`.
34
+
35
+ Then ask your agent: *"Read design/instructions/context.md and set up our context."* It interviews
36
+ you first - who is involved, where each relationship happens, what is private - then drafts from
37
+ evidence: the shipped record from deploy runs and the changelog, the map from the code, contracts
38
+ for the capabilities that matter now. Where nothing proves a claim, it writes `unknown`.
39
+
40
+ **Context that exists but is scattered** - specs out of date, nobody sure what is live - is a job for
41
+ the `reorganize-context` playbook: on a branch, never deleting, truth first (the shipped record),
42
+ then authority (current contracts), then files, proven by a blind before-and-after eval.
43
+
44
+ ## Evidence
45
+
46
+ Every availability claim in `shipped.md` carries a level and a citation:
47
+
48
+ - `confirmed` - observed in a system of record: the deploy job that ran for that service and
49
+ succeeded, a ci run that executed the suite.
50
+ - `reported` - a written claim, cited: a changelog line, an ops note.
51
+ - `unknown` - neither, with the reason.
52
+
53
+ An environment marked "deployed" is not proof on its own, and commit ancestry says a change is
54
+ inside a revision, not that the revision is running.
55
+
56
+ ## The check
57
+
58
+ ```bash
59
+ npx marver context check # exit 0 pass, 1 fail, 2 cannot determine
60
+ npx marver context check --base origin/main --body-file pr.md
61
+ npx marver context index # regenerate the index's capability table from the map
62
+ ```
63
+
64
+ It fails on:
65
+
66
+ - availability or a status line in a contract ("live since", "on production") - that is the
67
+ shipped record's alone;
68
+ - "section 20 wins where it differs" - one answer per rule;
69
+ - an evidence cell without a level, or `confirmed` and `reported` without a citation;
70
+ - a link or a `path:line` citation that does not resolve;
71
+ - the index over 800 words, or its capability table out of step with `map.json`;
72
+ - a `restricted` file tracked by git, or a `team` file reachable from a published board;
73
+ - a feedback item `shipped` without a `confirmed` availability, or a state off its list;
74
+ - `"status": "done"` on a board - Done comes only from the record;
75
+ - on a pull request, a change to a contracted capability's code without its contract, unless the
76
+ body says `no-contract-change: <capability> - <why>`.
77
+
78
+ It reports, without failing: playbooks gone stale since their last success, and source files the
79
+ map neither maps nor excludes (`"source"` in `map.json` names what counts).
80
+
81
+ In ci, with full history:
82
+
83
+ ```yaml
84
+ - uses: actions/checkout@v4
85
+ with: { fetch-depth: 0 }
86
+ - run: npx marver context check
87
+ ```
88
+
89
+ On a pull request it reads the base, head and body from GitHub's event.
90
+
91
+ ## On the canvas
92
+
93
+ Feature and project boards wear a status, read from these files - the first that matches wins:
94
+
95
+ | | When | Status |
96
+ |---|---|---|
97
+ | 1-3 | the board says `"status": "archived"`, `"paused"`, or `"blocked"` with a `"reason"` - by hand, or from the sidebar's **Change status…** | Archived · Paused · Blocked |
98
+ | 4 | the evidence cannot be read | Unknown |
99
+ | 5 | an open plan in `context/plans/` names the capability | In progress, filling by phase |
100
+ | 6 | `shipped.md` shows it available, `confirmed` - in a clause (one per `;`) with no negation or pending word ("nowhere", "not yet", "rolled back", "planned") and, for a product, no pre-production place (staging, preview, dev, local, testing) unless it also names production | Done |
101
+ | 7 | ... `reported` only | Done, reported |
102
+ | 8 | its contract is `state: current` | To do |
103
+ | 9 | anything else | Backlog |
104
+
105
+ A board finds its capability by its own name, or `"capability": "<slug>"`. The fill reads the
106
+ board's phase scenes - `<capability>-specs`, `-lofi`, then the capability itself - once each holds a
107
+ frame. The tooltip says what decided it. Change a file and the sidebar follows; no reload.
108
+ [Boards and folders](boards-and-folders.md) has the board types the status rides on.
109
+
110
+ ## Audiences
111
+
112
+ `team` - everyone with the repository, the default; `publishable` - may appear on a published canvas;
113
+ `restricted` - never tracked by git (a gitignored `context/private/`). A frame that renders a `team`
114
+ file onto a published board fails the check, and a published build never carries a board's status,
115
+ reason or capability unless its publish row opts in with `"showStatus": true` - and then only rows
116
+ 5 to 9, and only a status whose every source is publishable: a file under `context/` that says
117
+ `audience: publishable`, or a scene brief that does not say otherwise (it ships with its board).
package/docs/sharing.md CHANGED
@@ -119,7 +119,8 @@ the read-privacy work also called "v2" further down.)
119
119
  "max": "read", // required - the ceiling ("read" | "comment")
120
120
  "type": "design", // optional - the artifact type (default "mix")
121
121
  "open": "focus", // optional - the landing view
122
- "lock": true // optional - freeze the landing view (needs "open")
122
+ "lock": true, // optional - freeze the landing view (needs "open")
123
+ "showStatus": true // optional - show a feature board's status (0.22)
123
124
  }
124
125
  },
125
126
  "reveal": { "structure": true, "source": false }
@@ -130,7 +131,16 @@ the read-privacy work also called "v2" further down.)
130
131
  affects *access*; everything else is presentation.
131
132
  - **`type`** is one of `doc`, `slides`, `design`, `sketch`, `refs`, `mix`
132
133
  (default `mix`). It picks the board's default landing view and its card icon;
133
- nothing infers a type from content.
134
+ nothing infers a type from content. Since 0.22 a board's own type suggests one
135
+ - a `deck` board `slides`, a `context` board `refs`, a `project` `doc` - as a
136
+ note in `marver build`; the row decides.
137
+ - **`showStatus`** (0.22) shows a feature or project board's status on the
138
+ published canvas - only Backlog, To do, In progress, Done and Done reported,
139
+ only when every file it was read from is publishable - `audience: publishable`
140
+ under `context/`; a scene brief counts unless it says otherwise - never a
141
+ blocked reason or the evidence itself, and with the date the build read it.
142
+ Without it no status ships, and a published board never carries its
143
+ `status`, `reason` or `capability`.
134
144
  - **`open`** names the landing view: `canvas`, `board`, `present`, `focus`,
135
145
  or `slides`. `canvas` and `board` both land on the canvas; `present`,
136
146
  `focus`, and `slides` are their own modes. Absent means the type decides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,5 +1,9 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
- import { useStore, HAS_ALL_SCENES, PUBLISHED, fetchBoardTree, rememberTitles, type TreeBase } from './store.ts'
2
+ import { useStore, HAS_ALL_SCENES, PUBLISHED, STATUS_AS_OF, fetchBoardTree, rememberMeta, rememberTitles, type BoardMeta, type TreeBase } from './store.ts'
3
+ import { StatusIcon, TypeIcon } from './board-icons.tsx'
4
+ import { StatusPicker } from './StatusPicker.tsx'
5
+ import type { StatusWord } from '../../shared/board-types.ts'
6
+ import { PHASE_LABEL, STATUS_LABEL } from '../../shared/status.ts'
3
7
  import { canvasCtl } from './canvas/Canvas.tsx'
4
8
  import { Tip } from './Tip.tsx'
5
9
  import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
@@ -26,6 +30,14 @@ const readClosed = (): Record<string, true> => { try { return JSON.parse(localSt
26
30
  /** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
27
31
  * yet - drawn at `index` in `parent`'s items (null = the root), optionally with `board`
28
32
  * already inside it. */
33
+ /** A status's tooltip: what it is, how far along, why, and the evidence that decided it. */
34
+ function statusTip(m: BoardMeta): string {
35
+ const st = m.status!
36
+ const head = STATUS_LABEL[st.status] + (st.fill ? ` - ${PHASE_LABEL[st.fill]}` : '')
37
+ const asOf = STATUS_AS_OF ? `as of ${new Date(STATUS_AS_OF).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}` : null
38
+ return [head, st.reason, ...(st.evidence ?? []), asOf].filter(Boolean).join('\n')
39
+ }
40
+
29
41
  type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string; parent: string | null }
30
42
  /** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
31
43
  type Intent = (tree: TreeItem[]) => TreeItem[] | null
@@ -33,6 +45,7 @@ type Intent = (tree: TreeItem[]) => TreeItem[] | null
33
45
  export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
34
46
  const board = useStore((s) => s.board)
35
47
  const titles = useStore((s) => s.boardTitles) // board slug → title, off the last tree read
48
+ const meta = useStore((s) => s.boardMeta) // board slug → type and status (spec 20)
36
49
  const label = (n: string) => labelOf(n, titles[n]) // a board's label; a folder's is labelOf(name, item.title)
37
50
  const [tree, setTree] = useState<TreeItem[]>([])
38
51
  const [naming, setNaming] = useState<Naming | null>(null)
@@ -74,6 +87,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
74
87
  confirmedRef.current = snap.tree
75
88
  baseRef.current = snap.base
76
89
  rememberTitles(snap.titles) // the labels follow the same latest-wins rule
90
+ rememberMeta(snap.meta) // ...and so do types and statuses (spec 20)
77
91
  lastErr.current = ''
78
92
  show()
79
93
  return true
@@ -298,9 +312,23 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
298
312
  if (parent) setOpen(parent, true) // the new folder is drawn inside its parent: show it
299
313
  setNaming({ kind: 'new', index, board: withBoard, parent })
300
314
  }
315
+ /** A person's status decision (the picker): written into the board file, its hash checked - a
316
+ * write that lost a race re-reads and tries once more, like a rename. */
317
+ const setStatus = async (n: string, status: StatusWord | null, reason?: string) => {
318
+ let r = await useStore.getState().setBoardStatus(n, status, reason, baseRef.current.boards[n])
319
+ if (!r.ok && r.stale && await load()) r = await useStore.getState().setBoardStatus(n, status, reason, baseRef.current.boards[n])
320
+ if (!r.ok) { useStore.getState().toast(r.error ?? 'status not saved'); return }
321
+ refresh() // a clear: what the evidence says now
322
+ }
301
323
  const boardMenu = (n: string, parent: string | null): MenuItem[] => {
302
324
  const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
303
325
  if (PUBLISHED || n === 'all-scenes') return items
326
+ // a feature or project board: its status first, as Linear puts it - the picker opens in place
327
+ const m = useStore.getState().boardMeta[n]
328
+ if (m?.status && m.settable?.length) items.unshift({
329
+ label: 'Change status…', icon: <StatusIcon status={m.status.status} fill={m.status.fill} />,
330
+ panel: (close) => <StatusPicker meta={m} onPick={(s, reason) => { close(); void setStatus(n, s, reason) }} />,
331
+ })
304
332
  items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
305
333
  // the new folder takes the board's own slot at its own level (a sub-folder inside a top-level
306
334
  // folder; right after its sub-folder when it sits in one - that level holds no folders)
@@ -393,7 +421,8 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
393
421
  if (drop.list === null && HAS_ALL_SCENES) return { row: 'b:all-scenes', where: 'before', depth: 0 }
394
422
  return items.length ? { row: lastRow(items[items.length - 1]!), where: 'after', depth } : null
395
423
  })()
396
- const seamLeft = (depth: number): CSSProperties => ({ ['--seam-left' as string]: `${depth ? depth * INDENT : 6}px` })
424
+ // a nested seam starts where that list's rows start: the row's own 8px padding plus its indent
425
+ const seamLeft = (depth: number): CSSProperties => ({ ['--seam-left' as string]: `${depth ? 8 + depth * INDENT : 6}px` })
397
426
  const seamOf = (row: string): { cls: string; style?: CSSProperties } =>
398
427
  seam && seam.row === row ? { cls: ` drop-${seam.where}`, style: seamLeft(seam.depth) } : { cls: '' }
399
428
  const indent = (depth: number) => (depth >= 2 ? ' in-folder in-sub' : depth === 1 ? ' in-folder' : '')
@@ -420,8 +449,9 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
420
449
  onPointerUp={canDrag ? onPointerUp : undefined}
421
450
  onPointerCancel={canDrag ? () => resetPointer() : undefined}
422
451
  onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
423
- {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
452
+ {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : meta[n]?.type ? <TypeIcon type={meta[n].type!} /> : <CardsIcon size={14} />}
424
453
  <span>{label(n)}</span>
454
+ {meta[n]?.status && <i className="st" data-status={meta[n].status!.status} title={statusTip(meta[n])}><StatusIcon status={meta[n].status!.status} fill={meta[n].status!.fill} /></i>}
425
455
  </button>
426
456
  )
427
457
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { useStore, SOURCE_REVEALED } from './store.ts'
4
4
 
@@ -16,7 +16,9 @@ export function copyToClipboard(text: string, okMsg: string) {
16
16
  export const framePath = (board: string, f: { id: string; file: string }) =>
17
17
  SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
18
18
 
19
- export type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
19
+ /** A menu row. `panel` makes it open a panel IN PLACE of the menu (a picker - the board's status),
20
+ * drawn by the caller and handed `close`; `onClick` then is not called. */
21
+ export type MenuItem = { label: string; icon: ReactNode; onClick?: () => void; panel?: (close: () => void) => ReactNode }
20
22
  export type MenuState = { x: number; y: number; items: MenuItem[] }
21
23
  export type MenuOpener = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void
22
24
 
@@ -38,6 +40,15 @@ export function useContextMenu() {
38
40
 
39
41
  export function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
40
42
  const ref = useRef<HTMLDivElement>(null)
43
+ const [panel, setPanel] = useState<MenuItem['panel'] | null>(null)
44
+ const [top, setTop] = useState<number | null>(null)
45
+ useEffect(() => { setPanel(null); setTop(null) }, [menu]) // a new menu starts as a menu
46
+ // a panel is taller than the menu it replaces: keep it inside the window
47
+ useLayoutEffect(() => {
48
+ if (!menu || !ref.current) return
49
+ const h = ref.current.getBoundingClientRect().height
50
+ setTop(Math.max(8, Math.min(menu.y, window.innerHeight - h - 8)))
51
+ }, [menu, panel])
41
52
  useEffect(() => {
42
53
  if (!menu) return
43
54
  const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
@@ -49,9 +60,9 @@ export function ContextMenu({ menu, close }: { menu: MenuState | null; close: ()
49
60
  const app = document.querySelector('.sh-app')
50
61
  if (!menu || !app) return null
51
62
  return createPortal(
52
- <div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
53
- {menu.items.map((it) => (
54
- <button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
63
+ <div className={`sh-menu sh-ctxmenu${panel ? ' sh-ctxpanel' : ''}`} ref={ref} style={{ left: menu.x, top: top ?? menu.y }}>
64
+ {panel ? panel(close) : menu.items.map((it) => (
65
+ <button key={it.label} onClick={() => { if (it.panel) { setPanel(() => it.panel!); return } it.onClick?.(); close() }}>{it.icon}<span>{it.label}</span></button>
55
66
  ))}
56
67
  </div>,
57
68
  app,
@@ -0,0 +1,91 @@
1
+ import { useState, type KeyboardEvent } from 'react'
2
+ import { StatusIcon } from './board-icons.tsx'
3
+ import { STATUS_LABEL, type Status } from '../../shared/status.ts'
4
+ import type { StatusWord } from '../../shared/board-types.ts'
5
+ import type { BoardMeta } from './store.ts'
6
+
7
+ /**
8
+ * A board's status, from its right-click menu - Linear's picker, on Marver's rule (spec 20): a
9
+ * person decides only what the evidence cannot. With `context/` that is Blocked (with a reason),
10
+ * Paused and Archived, and "Back to the evidence" undoes the decision; without it, Backlog, To do
11
+ * and In progress as well. Done is never offered - it comes from the shipped record. The status
12
+ * the evidence gives shows read-only at the top, with where it came from.
13
+ *
14
+ * Type to filter, arrows and Enter, or a number. Blocked asks why before it writes.
15
+ */
16
+ type Opt = { key: string; status: StatusWord | null; label: string; icon: Status | 'clear' }
17
+
18
+ export function StatusPicker({ meta, onPick }: { meta: BoardMeta; onPick: (status: StatusWord | null, reason?: string) => void }) {
19
+ const settable = meta.settable ?? []
20
+ const current = meta.status
21
+ const hasContext = !settable.includes('todo')
22
+ const decided = !!current && (current.row ?? 9) <= 3 // rows 1-3: a decision on the board
23
+ const opts: Opt[] = [
24
+ ...settable.map((s) => ({ key: s, status: s, label: s === 'blocked' ? 'Blocked…' : STATUS_LABEL[s], icon: s })),
25
+ ...(hasContext && decided ? [{ key: 'clear', status: null, label: 'Back to the evidence', icon: 'clear' as const }] : []),
26
+ ]
27
+ const [q, setQ] = useState('')
28
+ const shown = opts.map((o, i) => ({ ...o, n: i + 1 })).filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase()))
29
+ const [hi, setHi] = useState(0)
30
+ const [asking, setAsking] = useState(false)
31
+ const [reason, setReason] = useState(current?.status === 'blocked' ? current.reason ?? '' : '')
32
+
33
+ const choose = (o: Opt | undefined) => {
34
+ if (!o) return
35
+ if (o.status === 'blocked') { setAsking(true); return }
36
+ onPick(o.status)
37
+ }
38
+ const keys = (e: KeyboardEvent<HTMLInputElement>) => {
39
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHi((h) => Math.min(h + 1, shown.length - 1)) }
40
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setHi((h) => Math.max(h - 1, 0)) }
41
+ else if (e.key === 'Enter') { e.preventDefault(); choose(shown[hi]) }
42
+ else if (/^[1-9]$/.test(e.key) && !q) { e.preventDefault(); choose(opts[Number(e.key) - 1]) }
43
+ }
44
+
45
+ if (asking) {
46
+ const ok = !!reason.trim()
47
+ return (
48
+ <div className="sh-status-picker" data-status-picker="reason">
49
+ <div className="sp-head"><StatusIcon status="blocked" /><b>Blocked - why?</b></div>
50
+ <input autoFocus className="sp-input" value={reason} maxLength={300} placeholder="Waiting on…"
51
+ onChange={(e) => setReason(e.target.value)}
52
+ onKeyDown={(e) => { if (e.key === 'Enter' && ok) { e.preventDefault(); onPick('blocked', reason.trim()) } }} />
53
+ <div className="sp-foot">{ok ? 'Enter to save' : 'A blocked board says why'} · Esc to close</div>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ // what the evidence says, when no decision hides it: the status and the line that decided it
59
+ const evidence = !decided && current && hasContext ? current.evidence?.[0] : undefined
60
+ return (
61
+ <div className="sh-status-picker" data-status-picker="list">
62
+ <input autoFocus className="sp-input" value={q} placeholder="Change status…"
63
+ onChange={(e) => { setQ(e.target.value); setHi(0) }} onKeyDown={keys} />
64
+ {current && evidence && (
65
+ <div className="sp-now" title={current.evidence?.join('\n')}>
66
+ <StatusIcon status={current.status} fill={current.fill} />
67
+ <span>{STATUS_LABEL[current.status]}</span>
68
+ <small>{evidence.replace(/:\s.*$/, '').replace(/^context\//, '')}</small>
69
+ </div>
70
+ )}
71
+ <div className="sp-list">
72
+ {shown.map((o, i) => {
73
+ const isCurrent = o.status !== null && o.status === current?.status && (decided || !hasContext)
74
+ return (
75
+ <button key={o.key} data-status-option={o.key} className={i === hi ? 'hi' : undefined}
76
+ onMouseEnter={() => setHi(i)} onClick={() => choose(o)}>
77
+ {o.icon === 'clear'
78
+ ? <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M3.2 6.2 A4.2 4.2 0 1 1 4.4 10.4 M3.2 3.4 V6.2 H6" /></svg>
79
+ : <StatusIcon status={o.icon} />}
80
+ <span>{o.label}</span>
81
+ {isCurrent && <svg className="sp-chk" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M2.5 6.3 L5 8.6 L9.6 3.6" /></svg>}
82
+ <kbd>{o.n}</kbd>
83
+ </button>
84
+ )
85
+ })}
86
+ {!shown.length && <div className="sp-empty">No status matches</div>}
87
+ </div>
88
+ <div className="sp-foot">{hasContext ? 'Backlog, To do, In progress and Done are read from context/' : 'Done needs a record - context/shipped.md'}</div>
89
+ </div>
90
+ )
91
+ }
@@ -0,0 +1,75 @@
1
+ import { useId, type ReactNode } from 'react'
2
+ import type { BoardType } from '../../shared/board-types.ts'
3
+ import type { Phase, Status } from '../../shared/status.ts'
4
+
5
+ /**
6
+ * Spec 20's glyphs, custom in a 14-unit space so they sit on the sidebar's 14px rows: one per
7
+ * board type (stroked in currentColor, so a row's colour - accent when current - carries over),
8
+ * and Linear-style status rings (their own colours: a status reads the same in every theme).
9
+ */
10
+
11
+ const TYPE_PATHS: Record<Exclude<BoardType, 'plain'>, ReactNode> = {
12
+ start: <path d="M2.5 6.6 L7 2.8 L11.5 6.6 V11.5 H8.4 V8.6 H5.6 V11.5 H2.5 Z" />,
13
+ feature: <path d="M7 1.9 L11.8 4.6 V9.4 L7 12.1 L2.2 9.4 V4.6 Z M2.2 4.6 L7 7.3 L11.8 4.6 M7 7.3 V12.1" />,
14
+ surface: <><rect x="2" y="2.8" width="10" height="8.4" rx="1.4" /><path d="M2 5.4 H12" /></>,
15
+ project: <><rect x="2" y="4.4" width="10" height="7" rx="1.2" /><path d="M5 4.4 V3 H9 V4.4 M2 7.4 H12" /></>,
16
+ feedback: <path d="M2.4 3.2 H11.6 V9.2 H6.2 L3.8 11.2 V9.2 H2.4 Z" />,
17
+ context: <path d="M2 8 L3.6 3 H10.4 L12 8 V11.2 H2 Z M2 8 H5 L5.8 9.3 H8.2 L9 8 H12" />,
18
+ deck: <><rect x="2" y="2.4" width="10" height="6.6" rx="1" /><path d="M7 9 V11.6 M5 11.6 H9" /></>,
19
+ archive: <><rect x="1.8" y="3" width="10.4" height="3" rx=".8" /><path d="M2.8 6 V11.2 H11.2 V6 M5.8 8.2 H8.2" /></>,
20
+ }
21
+
22
+ /** A board type's glyph; `plain` is null - the caller keeps its own board icon. */
23
+ export function TypeIcon({ type, size = 14 }: { type: BoardType; size?: number }) {
24
+ if (type === 'plain') return null
25
+ return (
26
+ <svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" strokeLinecap="round" aria-hidden data-type-icon={type}>
27
+ {TYPE_PATHS[type]}
28
+ </svg>
29
+ )
30
+ }
31
+
32
+ const GRAY = 'var(--glass-ink-3)'
33
+ const YELLOW = '#e2b203'
34
+ const RED = '#e5484d'
35
+ // done is the content palette's green (Apple's systemGreen, per theme - styles.css), the green Marver
36
+ // keeps for done alone; archived is Apple's brown - a cardboard box - per theme as well
37
+ const GREEN = 'var(--status-done, #34c759)'
38
+ const ARCHIVED = 'var(--status-archived, #956d51)'
39
+
40
+ /** A pie wedge of the ring's interior, `f` of the way round from twelve o'clock. */
41
+ const pie = (f: number, r = 3.4) => {
42
+ const a = f * 2 * Math.PI
43
+ const x = 7 + r * Math.sin(a), y = 7 - r * Math.cos(a)
44
+ return `M7 7 L7 ${7 - r} A${r} ${r} 0 ${f > 0.5 ? 1 : 0} 1 ${x.toFixed(2)} ${y.toFixed(2)} Z`
45
+ }
46
+
47
+ /** A status glyph, on one rule: a status still open is an outline in its colour (a ring - In progress
48
+ * fills it by phase: a quarter at spec, half at lo-fi, three quarters at hi-fi); a settled one is
49
+ * filled - Done a green disc, Archived a solid archive box (no ring: it is out of the flow, not a
50
+ * step in it). Done, reported stays an outline: a written claim is not settled until confirmed. */
51
+ export function StatusIcon({ status, fill, size = 14 }: { status: Status; fill?: Phase; size?: number }) {
52
+ const ring = (c: string, dash?: string) => <circle cx="7" cy="7" r="5.8" fill="none" stroke={c} strokeWidth="1.5" strokeDasharray={dash} />
53
+ // Done's check is cut OUT of its disc, not painted on it: the row behind shows through - the
54
+ // panel, a hover, the current row's wash - in either theme (a painted white check glared in dark)
55
+ const cut = `mv-st-${useId().replace(/[^\w-]/g, '')}`
56
+ return (
57
+ <svg width={size} height={size} viewBox="0 0 14 14" aria-hidden data-status-icon={status}>
58
+ {status === 'backlog' && ring(GRAY, '1.3 1.75')}
59
+ {status === 'todo' && ring(GRAY)}
60
+ {status === 'in-progress' && (<>{ring(YELLOW)}{fill && <path d={pie(fill / 4)} fill={YELLOW} />}</>)}
61
+ {status === 'blocked' && (<>{ring(RED)}<circle cx="7" cy="7" r="3.2" fill={RED} /></>)}
62
+ {status === 'done' && (<>
63
+ <mask id={cut}><rect width="14" height="14" fill="#fff" /><path d="M4.3 7.2 L6.2 9.1 L9.8 5.2" fill="none" stroke="#000" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></mask>
64
+ <circle cx="7" cy="7" r="6.5" fill={GREEN} mask={`url(#${cut})`} />
65
+ </>)}
66
+ {status === 'done-reported' && (<>{ring(GREEN)}<path d="M4.4 7.2 L6.2 9 L9.7 5.3" fill="none" stroke={GREEN} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></>)}
67
+ {status === 'unknown' && (<>{ring(GRAY)}<path d="M5.4 5.6 A1.7 1.7 0 1 1 7.6 7.2 C7.1 7.4 7 7.7 7 8.2" fill="none" stroke={GRAY} strokeWidth="1.3" strokeLinecap="round" /><circle cx="7" cy="10" r=".8" fill={GRAY} /></>)}
68
+ {status === 'paused' && (<>{ring(GRAY)}<rect x="4.9" y="4.4" width="1.4" height="5.2" rx=".5" fill={GRAY} /><rect x="7.7" y="4.4" width="1.4" height="5.2" rx=".5" fill={GRAY} /></>)}
69
+ {status === 'archived' && (<>
70
+ <rect x="1.6" y="2.6" width="10.8" height="3.2" rx=".9" fill={ARCHIVED} />
71
+ <path fillRule="evenodd" fill={ARCHIVED} d="M2.6 6.6 H11.4 V10.5 Q11.4 11.6 10.3 11.6 H3.7 Q2.6 11.6 2.6 10.5 Z M5.95 8.1 H8.05 A.65 .65 0 0 1 8.05 9.4 H5.95 A.65 .65 0 0 1 5.95 8.1 Z" />
72
+ </>)}
73
+ </svg>
74
+ )
75
+ }