@marver-design/marver 0.20.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 +123 -0
  2. package/README.md +17 -4
  3. package/dist/{bake-tAb0D6Rc.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-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
  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-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
  11. package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
  12. package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
  15. package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
  16. package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
  17. package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
  18. package/dist/{shot-iw3SEcpn.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 +143 -54
  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 +93 -14
  28. package/src/client/shell/styles.css +41 -9
  29. package/src/shared/board-tree.ts +291 -143
  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 +4 -3
  34. package/templates/AGENTS-studio.md +4 -3
  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 +78 -22
  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-BmxcT3Lc.mjs +0 -290
  45. package/dist/boards-PuVzw5Wp.mjs +0 -62
@@ -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.20.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,17 +1,22 @@
1
- import { useEffect, useRef, useState, 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'
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, 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'
6
10
  import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
7
11
  import {
8
- applyDrop, boardsIn, createFolder, deleteFolder, folderIn, folderOf, foldersIn, humanize, isOwnSlot, labelOf, moveBoard, newFolderSlot, readTitle, resolveDrop, retitleFolder, rootIndex, slugFor,
12
+ applyDrop, boardsIn, createFolder, deleteFolder, depthOf, folderEntries, folderIn, folderOf, foldersIn, holdsFolders, humanize, INDENT, isOwnSlot, labelOf, listIn, moveBoard, moveFolderToRoot, newFolderSlot, parentOf, readTitle,
13
+ resolveDrop, retitleFolder, rootIndex, slugFor,
9
14
  type Drag, type Drop, type Folder, type Row, type TreeItem,
10
15
  } from '../../shared/board-tree.ts'
11
16
 
12
17
  /**
13
- * Boards live at the top of the sidebar - always visible, one click to switch - in ONE level
14
- * of folders. The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
18
+ * Boards live at the top of the sidebar - always visible, one click to switch - in up to TWO
19
+ * levels of folders (a folder holds boards and folders; a sub-folder holds boards). The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
15
20
  * fields plus the `_folders.json` registry; every mutation here (drag, new/rename/delete
16
21
  * folder, move) is one optimistic tree write through `arrangeBoards`, replayed once on a
17
22
  * 409 (someone else wrote first). The list refreshes on mount, window focus, a slow poll and
@@ -23,14 +28,24 @@ const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer prefe
23
28
  const readClosed = (): Record<string, true> => { try { return JSON.parse(localStorage.getItem(CLOSED_KEY) ?? '{}') } catch { return {} } }
24
29
 
25
30
  /** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
26
- * yet - drawn at root `index`, optionally with `board` already inside it. */
27
- type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string }
31
+ * yet - drawn at `index` in `parent`'s items (null = the root), optionally with `board`
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
+
41
+ type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string; parent: string | null }
28
42
  /** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
29
43
  type Intent = (tree: TreeItem[]) => TreeItem[] | null
30
44
 
31
45
  export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
32
46
  const board = useStore((s) => s.board)
33
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)
34
49
  const label = (n: string) => labelOf(n, titles[n]) // a board's label; a folder's is labelOf(name, item.title)
35
50
  const [tree, setTree] = useState<TreeItem[]>([])
36
51
  const [naming, setNaming] = useState<Naming | null>(null)
@@ -72,6 +87,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
72
87
  confirmedRef.current = snap.tree
73
88
  baseRef.current = snap.base
74
89
  rememberTitles(snap.titles) // the labels follow the same latest-wins rule
90
+ rememberMeta(snap.meta) // ...and so do types and statuses (spec 20)
75
91
  lastErr.current = ''
76
92
  show()
77
93
  return true
@@ -185,7 +201,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
185
201
  refresh()
186
202
  return
187
203
  }
188
- const folderLabels = tree.filter((it): it is Folder => it.kind === 'folder' && (n.kind !== 'folder' || it.name !== n.name)).map((it) => labelOf(it.name, it.title))
204
+ const folderLabels = folderEntries(tree).map((e) => e.folder).filter((f) => n.kind !== 'folder' || f.name !== n.name).map((f) => labelOf(f.name, f.title))
189
205
  if (n.kind === 'folder') {
190
206
  const current = folderIn(tree, n.name)
191
207
  if (!current || title === labelOf(n.name, current.title)) { setNaming(null); return }
@@ -196,11 +212,11 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
196
212
  }
197
213
  if (n.kind !== 'new') return
198
214
  if (folderLabels.includes(title)) { taken('folder'); return }
199
- const { index, board: withBoard } = n
215
+ const { index, board: withBoard, parent } = n
200
216
  const slug = slugFor(title, foldersIn(tree)) // "Old stuff" → old-stuff (-2 past a namesake); "🚀" alone → folder
201
217
  setNaming(null)
202
218
  setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
203
- if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined))) useStore.getState().toast('that board is gone - nothing changed')
219
+ if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined, parent))) useStore.getState().toast(withBoard ? 'that board is gone - nothing changed' : 'that folder is gone - nothing changed')
204
220
  } finally { commitBusy.current = false }
205
221
  }
206
222
 
@@ -210,7 +226,10 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
210
226
  const measure = (): Row[] => Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[data-board-row],[data-folder-row]') ?? []).map((el) => {
211
227
  const r = el.getBoundingClientRect()
212
228
  const folder = el.hasAttribute('data-folder-row')
213
- return { kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: folder ? null : (el.dataset.folder ?? null), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left }
229
+ return {
230
+ kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: (folder ? el.dataset.parent : el.dataset.folder) ?? null,
231
+ depth: Number(el.dataset.depth ?? 0), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left,
232
+ }
214
233
  })
215
234
  /** The drop target for the pointer at (x, y), or null: outside the panel (a release there
216
235
  * cancels), or a slot that would change nothing. Inside the panel there is always one - the
@@ -289,22 +308,50 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
289
308
  }, [])
290
309
 
291
310
  // ---- menus (flat lists, no submenus) ----
292
- const newFolderAt = (index: number, withBoard?: string) => setNaming({ kind: 'new', index, board: withBoard })
311
+ const newFolderAt = (index: number, withBoard?: string, parent: string | null = null) => {
312
+ if (parent) setOpen(parent, true) // the new folder is drawn inside its parent: show it
313
+ setNaming({ kind: 'new', index, board: withBoard, parent })
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
+ }
293
323
  const boardMenu = (n: string, parent: string | null): MenuItem[] => {
294
324
  const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
295
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
+ })
296
332
  items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
297
- // the new folder takes the board's own slot (or the slot after its current folder)
298
- items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(newFolderSlot(treeRef.current, n), n) })
333
+ // the new folder takes the board's own slot at its own level (a sub-folder inside a top-level
334
+ // folder; right after its sub-folder when it sits in one - that level holds no folders)
335
+ items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => { const at = newFolderSlot(treeRef.current, n); newFolderAt(at.index, n, at.parent) } })
299
336
  // moving into an EXISTING folder is a drag, not a menu - the list stays short
300
- if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => { const p = folderOf(t, n); return moveBoard(t, n, null, p ? rootIndex(t, 'folder', p) + 1 : undefined) }) })
337
+ if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
338
+ const p = folderOf(t, n)
339
+ const top = p ? parentOf(t, p) ?? p : null // lands right after the top-level folder it was in
340
+ return moveBoard(t, n, null, top ? rootIndex(t, 'folder', top) + 1 : undefined)
341
+ }) })
301
342
  return items
302
343
  }
303
- const folderMenu = (f: string, boards: string[]): MenuItem[] => {
304
- const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
344
+ const folderMenu = (f: string, boards: string[], parent: string | null): MenuItem[] => {
345
+ const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f}${parent ? ` (in ${parent})` : ''} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
305
346
  if (PUBLISHED) return items
306
347
  items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'folder', name: f }) })
307
- // folders organise, never own: deleting one puts its boards back at the top level, in order
348
+ // two levels: a top-level folder can hold folders; a sub-folder can leave its parent
349
+ if (!parent) items.push({ label: 'New folder inside', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(listIn(treeRef.current, f)?.length ?? 0, undefined, f) })
350
+ else items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
351
+ const p = parentOf(t, f)
352
+ return moveFolderToRoot(t, f, p ? rootIndex(t, 'folder', p) + 1 : undefined)
353
+ }) })
354
+ // folders organise, never own: deleting one moves what it holds up one level, into its place, in order
308
355
  items.push({ label: 'Delete folder', icon: <FolderMinusIcon size={15} />, onClick: () => { setOpen(f, true); mutate((t) => deleteFolder(t, f)) } })
309
356
  return items
310
357
  }
@@ -327,6 +374,15 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
327
374
  // eslint-disable-next-line react-hooks/exhaustive-deps
328
375
  }, [])
329
376
 
377
+ // a draft new folder whose parent vanished or got nested mid-naming is cancelled, said once
378
+ const orphanDraft = naming?.kind === 'new' && !holdsFolders(tree, naming.parent)
379
+ useEffect(() => {
380
+ if (!orphanDraft) return
381
+ setNaming(null)
382
+ useStore.getState().toast('that folder changed - the new folder was not made')
383
+ // eslint-disable-next-line react-hooks/exhaustive-deps
384
+ }, [orphanDraft])
385
+
330
386
  // ---- rows ----
331
387
  const input = (defaultValue: string, placeholder?: string) => (
332
388
  <input autoFocus defaultValue={defaultValue} placeholder={placeholder} spellCheck={false}
@@ -337,26 +393,52 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
337
393
  }}
338
394
  onBlur={(e) => { if (namingRef.current) void commit(e.currentTarget.value) }} />
339
395
  )
340
- // ONE seam per gap, from the insertion index: drop-before the row AT that index, or drop-after
341
- // the last row of a folder's list. Overlay (::after), so no layout shift. Never on the row
342
- // being dragged. The root end slot draws before the pinned all-scenes row, never after a
343
- // folder header (that seam would read as "inside").
344
- const seam = (list: string | null, index: number, last: boolean): string => {
345
- if (!drag || !drop || 'into' in drop || drop.list !== list) return ''
346
- return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
396
+ // a new folder being named is drawn at its future slot, its board (if any) already inside;
397
+ // that board leaves its usual row for the duration. A draft whose parent vanished or can no
398
+ // longer hold folders (an agent deleted or nested it mid-naming) draws nothing and hides
399
+ // nothing - the effect below cancels it, so the board it held shows where it now is
400
+ const draftLive = naming?.kind === 'new' && holdsFolders(tree, naming.parent)
401
+ const draft = naming?.kind === 'new' && draftLive ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
402
+ const visible = (items: TreeItem[]) => (draft?.board ? items.filter((k) => !(k.kind === 'board' && k.name === draft.board)) : items)
403
+ const key = (it: TreeItem) => `${it.kind === 'board' ? 'b' : 'f'}:${it.name}`
404
+ /** The last row an item draws: a board's own, a closed or empty folder's header, else its last child's last row. */
405
+ const lastRow = (it: TreeItem): string => {
406
+ if (it.kind === 'board') return key(it)
407
+ const kids = visible(it.items)
408
+ return !closed[it.name] && kids.length ? lastRow(kids[kids.length - 1]!) : key(it)
347
409
  }
348
- const boardRow = (n: string, parent: string | null, index: number, last: boolean) => {
410
+ // ONE seam per gap, overlaid (::after, no layout shift) on the row the gap touches: before the
411
+ // first row of the item AT the index, or after the last row of a list's last item at its end.
412
+ // Its left edge follows the indent of the list it lands in, so "inside" and "outside" never
413
+ // draw alike. The root end draws before the pinned all-scenes row. An open EMPTY folder has no
414
+ // row to draw on: its header draws the indented seam under itself (drop-in).
415
+ const seam = ((): { row: string; where: 'before' | 'after'; depth: number } | null => {
416
+ if (!drag || !drop || 'into' in drop) return null
417
+ const items = listIn(tree, drop.list)
418
+ if (!items) return null
419
+ const depth = depthOf(tree, drop.list)
420
+ if (drop.index < items.length) return { row: key(items[drop.index]!), where: 'before', depth }
421
+ if (drop.list === null && HAS_ALL_SCENES) return { row: 'b:all-scenes', where: 'before', depth: 0 }
422
+ return items.length ? { row: lastRow(items[items.length - 1]!), where: 'after', depth } : null
423
+ })()
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` })
426
+ const seamOf = (row: string): { cls: string; style?: CSSProperties } =>
427
+ seam && seam.row === row ? { cls: ` drop-${seam.where}`, style: seamLeft(seam.depth) } : { cls: '' }
428
+ const indent = (depth: number) => (depth >= 2 ? ' in-folder in-sub' : depth === 1 ? ' in-folder' : '')
429
+
430
+ const boardRow = (n: string, parent: string | null, depth: number) => {
349
431
  const dragging = drag?.kind === 'board' && drag.name === n
350
- const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
432
+ const s = dragging ? { cls: '' } : seamOf(`b:${n}`)
351
433
  // the row being renamed is still a row: measured by a drag (its slot exists) and it draws its seam
352
434
  if (naming?.kind === 'board' && naming.name === n) return (
353
- <div key={n} data-board-row data-board={n} data-folder={parent ?? undefined} className={`it board editing${parent ? ' in-folder' : ''}${dropCls}`}><CardsIcon size={14} />{input(label(n))}</div>
435
+ <div key={`b:${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-depth={depth} className={`it board editing${indent(depth)}${s.cls}`} style={s.style}><CardsIcon size={14} />{input(label(n))}</div>
354
436
  )
355
437
  const canDrag = !PUBLISHED && n !== 'all-scenes'
356
438
  const item: Drag = { kind: 'board', name: n }
357
439
  return (
358
- <button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
359
- className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
440
+ <button key={`b:${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-depth={depth} data-reorderable={canDrag || undefined}
441
+ className={`it board${indent(depth)}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${s.cls}`} style={s.style}
360
442
  // draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
361
443
  // click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
362
444
  // (detail === 0) and non-draggable rows (all-scenes, published) switch here as normal
@@ -367,28 +449,48 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
367
449
  onPointerUp={canDrag ? onPointerUp : undefined}
368
450
  onPointerCancel={canDrag ? () => resetPointer() : undefined}
369
451
  onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
370
- {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} />}
371
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>}
372
455
  </button>
373
456
  )
374
457
  }
375
- const folderRow = (it: Folder, boards: string[], index: number): ReactNode[] => {
458
+ const draftRows = (depth: number): ReactNode[] => !draft ? [] : [
459
+ <div key="f:new" className={`it folder editing${indent(depth)}`}><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
460
+ ...(draft.board ? [<div key="new/board" className={`it board draft${indent(depth + 1)}${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
461
+ ]
462
+ /** A list's rows - the root's, a folder's, a sub-folder's - with a draft new folder at its slot. */
463
+ const listRows = (items: TreeItem[], parent: string | null, depth: number): ReactNode[] => {
464
+ const out: ReactNode[] = []
465
+ const here = draft && draft.parent === parent ? draft : null
466
+ items.forEach((it, i) => {
467
+ if (here && here.index === i) out.push(...draftRows(depth))
468
+ if (it.kind === 'board') { if (draft?.board !== it.name) out.push(boardRow(it.name, parent, depth)) }
469
+ else out.push(...folderRows(it, parent, depth))
470
+ })
471
+ if (here && here.index >= items.length) out.push(...draftRows(depth))
472
+ return out
473
+ }
474
+ const folderRows = (it: Folder, parent: string | null, depth: number): ReactNode[] => {
376
475
  const f = it.name
377
476
  const open = !closed[f]
477
+ const kids = visible(it.items)
478
+ const boards = boardsIn(kids)
479
+ const s = seamOf(`f:${f}`)
378
480
  const rows: ReactNode[] = []
379
481
  if (naming?.kind === 'folder' && naming.name === f) {
380
- rows.push(<div key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} className={`it folder editing${seam(null, index, false)}`}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
482
+ rows.push(<div key={`f:${f}`} data-folder-row={f} data-parent={parent ?? undefined} data-depth={depth} data-open={open ? '1' : '0'} className={`it folder editing${indent(depth)}${s.cls}`} style={s.style}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
381
483
  } else {
382
484
  const item: Drag = { kind: 'folder', name: f }
383
485
  const dragging = drag?.kind === 'folder' && drag.name === f
384
486
  const into = !!drag && !!drop && 'into' in drop && drop.into === f
385
- // a slot inside an open EMPTY folder has no board row to draw on: the indented seam sits under the header
386
- const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && boards.length === 0
487
+ const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && kids.length === 0
488
+ const sc = dragging ? { cls: '' } : inSeam ? { cls: ' drop-in', style: seamLeft(depth + 1) } : s
387
489
  rows.push(
388
- <button key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
389
- className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${inSeam ? ' drop-in' : ''}${dragging ? '' : seam(null, index, false)}`}
490
+ <button key={`f:${f}`} data-folder-row={f} data-parent={parent ?? undefined} data-depth={depth} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
491
+ className={`it folder${indent(depth)}${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${sc.cls}`} style={sc.style}
390
492
  onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
391
- onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
493
+ onContextMenu={(e) => onMenu(e, folderMenu(f, boards, parent))}
392
494
  onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
393
495
  onPointerMove={!PUBLISHED ? onPointerMove : undefined}
394
496
  onPointerUp={!PUBLISHED ? onPointerUp : undefined}
@@ -400,25 +502,12 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
400
502
  </button>,
401
503
  )
402
504
  }
403
- if (open) boards.forEach((b, i) => rows.push(boardRow(b, f, i, i === boards.length - 1)))
505
+ if (open) rows.push(...listRows(it.items, f, depth + 1))
404
506
  return rows
405
507
  }
406
508
 
407
- // a new folder being named is drawn at its future slot, its board (if any) already inside;
408
- // that board leaves its usual row for the duration
409
- const draft = naming?.kind === 'new' ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
410
- const rows: ReactNode[] = []
411
- const draftRows = draft ? [
412
- <div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
413
- ...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
414
- ] : []
415
- tree.forEach((it, i) => {
416
- if (draft && draft.index === i) rows.push(...draftRows)
417
- if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
418
- else rows.push(...folderRow(it, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
419
- })
420
- if (draft && draft.index >= tree.length) rows.push(...draftRows)
421
- if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
509
+ const rows: ReactNode[] = listRows(tree, null, 0)
510
+ if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, 0)) // a published bundle without it shows none
422
511
  return (
423
512
  <div className="sh-boards" ref={rootRef} onContextMenu={(e: ReactMouseEvent) => blankMenu(e)}>
424
513
  <div className="hd">
@@ -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
+ }