@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.
- package/CHANGELOG.md +123 -0
- package/README.md +17 -4
- package/dist/{bake-tAb0D6Rc.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-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
- package/dist/cli.mjs +67 -8
- package/dist/config-DJxMRVD8.mjs +373 -0
- package/dist/context-DUAENlJ6.mjs +600 -0
- package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
- package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
- package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
- package/dist/managed-write-Bo-oPc-i.mjs +71 -0
- package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
- package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
- package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
- package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
- package/dist/{shot-iw3SEcpn.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 +143 -54
- 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 +93 -14
- package/src/client/shell/styles.css +41 -9
- package/src/shared/board-tree.ts +291 -143
- 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 +4 -3
- package/templates/AGENTS-studio.md +4 -3
- 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 +78 -22
- 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-BmxcT3Lc.mjs +0 -290
- package/dist/boards-PuVzw5Wp.mjs +0 -62
package/docs/context.md
ADDED
|
@@ -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
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
27
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
298
|
-
|
|
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) => {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
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${
|
|
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={
|
|
359
|
-
className={`it board${
|
|
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
|
|
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${
|
|
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
|
-
|
|
386
|
-
const
|
|
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' : ''}${
|
|
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)
|
|
505
|
+
if (open) rows.push(...listRows(it.items, f, depth + 1))
|
|
404
506
|
return rows
|
|
405
507
|
}
|
|
406
508
|
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
+
}
|