@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
|
@@ -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
|
+
}
|
|
@@ -17,6 +17,10 @@ const DATA: {
|
|
|
17
17
|
tree?: TreeItem[]
|
|
18
18
|
/** slug → title of the published boards (0.16.1 bundles); folder titles ride on `tree` */
|
|
19
19
|
titles?: Record<string, string>
|
|
20
|
+
/** slug → resolved type, and the status a publish row opted into (0.22 bundles; spec 20) */
|
|
21
|
+
meta?: Record<string, BoardMeta>
|
|
22
|
+
/** when the build read the statuses it ships - a published status is a snapshot */
|
|
23
|
+
statusAsOf?: string
|
|
20
24
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
21
25
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
22
26
|
/** the generation of the glass textures this build shipped (publish-bakes.ts); absent = none */
|
|
@@ -25,6 +29,8 @@ const DATA: {
|
|
|
25
29
|
|
|
26
30
|
/** The published textures' generation, or 0: the static index this build shipped is at /__mv/bakes/<gen>/index.json. */
|
|
27
31
|
export const BAKES = DATA?.bakes ?? 0
|
|
32
|
+
/** When a published bundle's statuses were read (spec 20) - absent in dev, where they are live. */
|
|
33
|
+
export const STATUS_AS_OF = DATA?.statusAsOf ?? null
|
|
28
34
|
|
|
29
35
|
/** Every published board is locked to a stage mode - the canvas shell is never
|
|
30
36
|
* offered on this bundle (01-sharing §5.1's all-boards rule). */
|
|
@@ -138,23 +144,35 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
138
144
|
export { cap, humanize } from './labels.ts'
|
|
139
145
|
import { cap, humanize } from './labels.ts'
|
|
140
146
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
141
|
-
import { buildTree, flatten, labelOf, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
147
|
+
import { buildTree, flatten, labelOf, toWire, TREE_PROTOCOL, type TreeItem } from '../../shared/board-tree.ts'
|
|
148
|
+
import type { BoardType, StatusWord } from '../../shared/board-types.ts'
|
|
149
|
+
import type { Phase, Status } from '../../shared/status.ts'
|
|
142
150
|
|
|
143
151
|
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
144
152
|
* the folder registry (null = there was no file). */
|
|
145
153
|
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
146
|
-
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
|
|
154
|
+
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string>; meta: Record<string, BoardMeta> }
|
|
155
|
+
/** What spec 20 adds to a board's row: its resolved type, and - for feature and project boards -
|
|
156
|
+
* its status read from context/. Dev carries the evidence for the tooltip; a published bundle
|
|
157
|
+
* carries only what its publish row opted into. */
|
|
158
|
+
export interface BoardMeta {
|
|
159
|
+
type?: BoardType
|
|
160
|
+
/** `row` is the table row that decided it (1-3: a decision on the board) */
|
|
161
|
+
status?: { status: Status; row?: number; fill?: Phase; reason?: string; evidence?: string[] }
|
|
162
|
+
/** what a person may set it to from the sidebar (dev only) - shared/board-types settableStatuses */
|
|
163
|
+
settable?: StatusWord[]
|
|
164
|
+
}
|
|
147
165
|
|
|
148
|
-
/** The sidebar tree: root boards and folders in rank order, each folder's boards
|
|
149
|
-
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
166
|
+
/** The sidebar tree: root boards and folders in rank order, each folder's boards and
|
|
167
|
+
* sub-folders inside (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
150
168
|
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
151
169
|
* (the server's 422 message) - callers keep their last known tree. */
|
|
152
170
|
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
153
|
-
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null }, titles: DATA.titles ?? {} }
|
|
171
|
+
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null }, titles: DATA.titles ?? {}, meta: DATA.meta ?? {} }
|
|
154
172
|
const [boards, reg] = await Promise.all([
|
|
155
|
-
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
|
|
173
|
+
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string; type?: BoardType; status?: BoardMeta['status'] | null; settable?: StatusWord[] }[]>,
|
|
156
174
|
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
157
|
-
const j = await r.json() as { folders?: { name: string; order?: number; title?: string }[]; sha256?: string | null; error?: string }
|
|
175
|
+
const j = await r.json() as { folders?: { name: string; order?: number; parent?: string; title?: string }[]; sha256?: string | null; error?: string }
|
|
158
176
|
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
159
177
|
return j
|
|
160
178
|
}),
|
|
@@ -163,8 +181,13 @@ export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
|
163
181
|
tree: buildTree(boards, reg.folders ?? []),
|
|
164
182
|
base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
|
|
165
183
|
titles: Object.fromEntries(boards.flatMap((b) => (b.title ? [[b.name, b.title]] : []))),
|
|
184
|
+
meta: Object.fromEntries(boards.map((b) => [b.name, { ...(b.type && b.type !== 'plain' ? { type: b.type } : {}), ...(b.status ? { status: b.status } : {}), ...(b.settable?.length ? { settable: b.settable } : {}) }])),
|
|
166
185
|
}
|
|
167
186
|
}
|
|
187
|
+
/** A tree read's types and statuses, kept under the same latest-wins rule as its titles. */
|
|
188
|
+
export function rememberMeta(meta: Record<string, BoardMeta>) {
|
|
189
|
+
if (JSON.stringify(useStore.getState().boardMeta) !== JSON.stringify(meta)) useStore.setState({ boardMeta: meta })
|
|
190
|
+
}
|
|
168
191
|
/** A tree read's board titles become the labels' - called by the reader once it knows the
|
|
169
192
|
* read is its latest (an older response must not overwrite a newer one's labels). Folder
|
|
170
193
|
* titles ride on the tree's folder items: a board and a folder may share a slug, never one map. */
|
|
@@ -179,6 +202,7 @@ export async function fetchBoardNames(): Promise<string[]> {
|
|
|
179
202
|
if (DATA) return DATA.names
|
|
180
203
|
const snap = await fetchBoardTree()
|
|
181
204
|
rememberTitles(snap.titles)
|
|
205
|
+
rememberMeta(snap.meta)
|
|
182
206
|
return [...flatten(snap.tree), 'all-scenes']
|
|
183
207
|
}
|
|
184
208
|
/** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
|
|
@@ -322,7 +346,9 @@ interface State {
|
|
|
322
346
|
imagePulse: number // bumps on each successful image copy - same flash, the images-square icon
|
|
323
347
|
imageBusy: boolean // a copy-as-image render is in flight (one at a time)
|
|
324
348
|
|
|
325
|
-
|
|
349
|
+
/** (re)load the open board from disk. `mayCommit`, when given, is asked again at the moment the
|
|
350
|
+
* reload would land - a caller's own conditions (a status write's: nothing moved since its flush) */
|
|
351
|
+
boot(mayCommit?: () => boolean): Promise<boolean>
|
|
326
352
|
applyManifest(m: Manifest): void
|
|
327
353
|
frameFor(node: Node): FrameEntry | undefined
|
|
328
354
|
moveNode(key: string, x: number, y: number): void
|
|
@@ -353,10 +379,16 @@ interface State {
|
|
|
353
379
|
switchBoard(name: string): Promise<void>
|
|
354
380
|
/** what the sidebar labels boards by: slug → title, off the last tree read */
|
|
355
381
|
boardTitles: Record<string, string>
|
|
382
|
+
/** what the sidebar draws beside a board: its type and status (spec 20), off the last tree read */
|
|
383
|
+
boardMeta: Record<string, BoardMeta>
|
|
356
384
|
/** retitle a board - what humans see ('' = back to the Title-Cased slug). The file never
|
|
357
385
|
* moves: its name is the board's identity (agents, publish.json, URLs, comment threads).
|
|
358
386
|
* `baseHash` = the file as last seen; a 409 (`stale`) means someone wrote it since. */
|
|
359
387
|
renameBoard(name: string, title: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
|
|
388
|
+
/** a board's status, decided by a person (the sidebar's picker): one of its `settable` statuses,
|
|
389
|
+
* or null to clear the decision (back to the evidence); blocked carries its reason. Only
|
|
390
|
+
* `status` and `reason` change in the file; a 409 (`stale`) means someone wrote it since. */
|
|
391
|
+
setBoardStatus(name: string, status: StatusWord | null, reason?: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
|
|
360
392
|
/** a scene's title, into its brief's front matter (the directory never moves) */
|
|
361
393
|
renameScene(scene: string, title: string): Promise<{ ok: boolean; error?: string }>
|
|
362
394
|
/** `sh:scenes`: the scenes changed (a brief's title or description) with the frames intact */
|
|
@@ -669,10 +701,10 @@ export const useStore = create<State>((set, get) => {
|
|
|
669
701
|
return {
|
|
670
702
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
671
703
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
672
|
-
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {},
|
|
704
|
+
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {}, boardMeta: DATA?.meta ?? {},
|
|
673
705
|
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
|
|
674
706
|
|
|
675
|
-
async boot() {
|
|
707
|
+
async boot(mayCommit) {
|
|
676
708
|
const seq = ++loadSeq
|
|
677
709
|
const boardName = get().board
|
|
678
710
|
const revAtStart = editRev, scenesAtStart = scenesRev
|
|
@@ -680,7 +712,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
680
712
|
if (seq !== loadSeq) return false // a newer load superseded this one
|
|
681
713
|
if (!next) { get().toast(`board "${boardName}" failed to load`); return false }
|
|
682
714
|
// the user kept editing while we fetched - their newer state wins over the reload
|
|
683
|
-
if (get().board !== boardName || editRev !== revAtStart) return false
|
|
715
|
+
if (get().board !== boardName || editRev !== revAtStart || (mayCommit && !mayCommit())) return false
|
|
684
716
|
const live = get().manifest // a WS manifest update may have landed mid-fetch
|
|
685
717
|
set(next)
|
|
686
718
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
@@ -738,12 +770,18 @@ export const useStore = create<State>((set, get) => {
|
|
|
738
770
|
// hash the rewrite is about to move
|
|
739
771
|
holdSaves()
|
|
740
772
|
}
|
|
773
|
+
const rev = editRev // setBoardStatus's guard on the reload
|
|
774
|
+
const mayReload = () => active && get().board === from && editRev === rev && !get().gesture
|
|
741
775
|
try {
|
|
742
776
|
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
743
777
|
// the sidebar looked); every other board's is the sidebar's
|
|
744
|
-
const
|
|
778
|
+
const send = (base: string | null | undefined) => postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) })
|
|
745
779
|
let res: Response
|
|
746
|
-
try {
|
|
780
|
+
try {
|
|
781
|
+
res = await send(active && get().boardHash ? get().boardHash : baseHash)
|
|
782
|
+
// the open board changed on disk: reload it, then once more (setBoardStatus's reasoning)
|
|
783
|
+
if (res.status === 409 && mayReload() && await get().boot(mayReload)) res = await send(get().boardHash)
|
|
784
|
+
}
|
|
747
785
|
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
748
786
|
const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
|
|
749
787
|
if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `rename failed (${res.status})` }
|
|
@@ -758,6 +796,47 @@ export const useStore = create<State>((set, get) => {
|
|
|
758
796
|
})
|
|
759
797
|
},
|
|
760
798
|
|
|
799
|
+
setBoardStatus(name, status, reason, baseHash) {
|
|
800
|
+
return structural(async () => {
|
|
801
|
+
const active = name === get().board
|
|
802
|
+
// the active board's file is rewritten under the autosave: flush first, hold across (renameBoard's pattern)
|
|
803
|
+
if (active) {
|
|
804
|
+
let ok = true
|
|
805
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
806
|
+
if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
|
|
807
|
+
holdSaves()
|
|
808
|
+
}
|
|
809
|
+
// what a reload may replace: this board as flushed - holdSaves pauses saving, not editing, so an
|
|
810
|
+
// edit, a drag or a board switch while the request is out means the reload is not ours to do
|
|
811
|
+
const rev = editRev
|
|
812
|
+
const mayReload = () => active && get().board === name && editRev === rev && !get().gesture
|
|
813
|
+
try {
|
|
814
|
+
const send = (base: string | null | undefined) => postOwner('boards/status', { name, status, ...(reason ? { reason } : {}), ...(base ? { baseHash: base } : {}) })
|
|
815
|
+
let res: Response
|
|
816
|
+
try {
|
|
817
|
+
res = await send(active && get().boardHash ? get().boardHash : baseHash)
|
|
818
|
+
// the open board changed on disk (an agent wrote it): its hash in the store is behind, and a
|
|
819
|
+
// retry with it would 409 again - reload the board (layout and hash together) when nothing
|
|
820
|
+
// has changed here since the flush, then try once more against what is on disk now
|
|
821
|
+
if (res.status === 409 && mayReload() && await get().boot(mayReload)) res = await send(get().boardHash)
|
|
822
|
+
}
|
|
823
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
824
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
|
|
825
|
+
if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `status failed (${res.status})` }
|
|
826
|
+
if (active && get().board === name && body?.sha256) set({ boardHash: body.sha256 })
|
|
827
|
+
// a decision shows at once; what the evidence says after a clear comes with the next tree read
|
|
828
|
+
if (status) {
|
|
829
|
+
const meta = { ...get().boardMeta }
|
|
830
|
+
const cur = meta[name] ?? {}
|
|
831
|
+
const row = ({ archived: 1, paused: 2, blocked: 3, 'in-progress': 5, done: 6, todo: 8, backlog: 9 } as const)[status]
|
|
832
|
+
meta[name] = { ...cur, status: { status, row, ...(status === 'blocked' && reason ? { reason } : {}), evidence: [`design/boards/${name}.json: "status": "${status}"`] } }
|
|
833
|
+
set({ boardMeta: meta })
|
|
834
|
+
}
|
|
835
|
+
return { ok: true }
|
|
836
|
+
} finally { if (active) releaseSaves() }
|
|
837
|
+
})
|
|
838
|
+
},
|
|
839
|
+
|
|
761
840
|
async renameScene(scene, title) {
|
|
762
841
|
let res: Response
|
|
763
842
|
try { res = await postOwner('scenes/rename', { scene, title }) }
|
|
@@ -795,7 +874,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
795
874
|
const active = get().board
|
|
796
875
|
const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
|
|
797
876
|
let res: Response
|
|
798
|
-
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
|
|
877
|
+
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders }, protocol: TREE_PROTOCOL }) }
|
|
799
878
|
catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
|
|
800
879
|
const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
|
|
801
880
|
if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
|
|
@@ -469,6 +469,15 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
469
469
|
.sh-panel .it.board svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
|
|
470
470
|
.sh-panel .it.board.cur { background: var(--accent-wash); color: var(--glass-accent); font-weight: 600 }
|
|
471
471
|
.sh-panel .it.board.cur svg { color: var(--glass-accent) }
|
|
472
|
+
/* spec 20: a feature or project board's status ring sits at the row's end, in its own colours -
|
|
473
|
+
never the accent of the current row - with its evidence in the tooltip */
|
|
474
|
+
.sh-panel .it.board .st { flex: none; display: inline-flex; margin-left: auto; font-style: normal }
|
|
475
|
+
/* the settled statuses' fills, one value per theme: done is the content palette's green (Apple's
|
|
476
|
+
systemGreen); archived is Apple's brown - its increased-contrast value on light, its default on
|
|
477
|
+
dark, where the increased-contrast one turns peach - so it reads as the same brown box in both */
|
|
478
|
+
.sh-app { --status-done: #34c759; --status-archived: #956d51 }
|
|
479
|
+
.sh-app.dark { --status-done: #30d158; --status-archived: #b78a66 }
|
|
480
|
+
.sh-panel .it.board .st svg, .sh-panel .it.board.cur .st svg { color: inherit; margin: 0 }
|
|
472
481
|
/* drag-to-reorder: a grab cursor, the lifted row dims, and a 2px accent rule marks the
|
|
473
482
|
drop seam above or below the hovered row */
|
|
474
483
|
/* a board row is click-to-switch first (pointer on hover); the grabbing hand appears the
|
|
@@ -484,7 +493,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
484
493
|
#0088ff - never the interact-mode purple that --glass-accent shifts to. */
|
|
485
494
|
.sh-panel .it.board.drop-before, .sh-panel .it.board.drop-after { position: relative }
|
|
486
495
|
.sh-panel .it.board.drop-before::after, .sh-panel .it.board.drop-after::after {
|
|
487
|
-
content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
|
|
496
|
+
content: ''; position: absolute; left: var(--seam-left, 6px); right: 6px; height: 3px; border-radius: 3px;
|
|
488
497
|
background: #0088ff; pointer-events: none }
|
|
489
498
|
.sh-panel .it.board.drop-before::after { top: -1px }
|
|
490
499
|
.sh-panel .it.board.drop-after::after { bottom: -1px }
|
|
@@ -493,28 +502,31 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
493
502
|
.sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
|
|
494
503
|
color: var(--glass-ink); font: inherit }
|
|
495
504
|
.sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
|
|
496
|
-
/* folders:
|
|
505
|
+
/* folders: two levels, same row grammar as boards (idle icon tertiary, count tertiary). A folder
|
|
497
506
|
holding the current board wears the ancestor wash so the active board's home survives a
|
|
498
|
-
collapse;
|
|
507
|
+
collapse; what it holds indents one step - a sub-folder's header one step, its boards two. Folder rows share the board seam rules; dropping INTO a
|
|
499
508
|
folder lights the whole row (wash + brand-blue ring) instead of a seam. */
|
|
500
509
|
.sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
|
|
501
510
|
.sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
|
|
502
511
|
.sh-panel .it.folder.dragging { opacity: .4 }
|
|
503
512
|
.sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
|
|
504
513
|
.sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
|
|
505
|
-
content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
|
|
514
|
+
content: ''; position: absolute; left: var(--seam-left, 6px); right: 6px; height: 3px; border-radius: 3px;
|
|
506
515
|
background: #0088ff; pointer-events: none }
|
|
507
516
|
.sh-panel .it.folder.drop-before::after { top: -1px }
|
|
508
517
|
.sh-panel .it.folder.drop-after::after { bottom: -1px }
|
|
509
518
|
.sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
|
|
510
519
|
.sh-panel .it.folder.drop-into svg { color: #0088ff }
|
|
511
|
-
|
|
520
|
+
/* 8px row padding + INDENT (22px, board-tree.ts) per level: a nested row's icon starts exactly
|
|
521
|
+
where its folder's name starts, at every level */
|
|
522
|
+
.sh-panel .it.board.in-folder, .sh-panel .it.folder.in-folder { padding-left: 30px }
|
|
523
|
+
.sh-panel .it.board.in-sub { padding-left: 52px }
|
|
512
524
|
.sh-panel .it.board.draft { cursor: default; opacity: .6 }
|
|
513
|
-
/* seams
|
|
514
|
-
|
|
515
|
-
|
|
525
|
+
/* seams are indented with the list they land in (--seam-left, set on the row that draws them),
|
|
526
|
+
so "inside", "inside the sub-folder" and "at the root" never draw alike; an open EMPTY folder
|
|
527
|
+
draws that indented seam under its own header (drop-in) */
|
|
516
528
|
.sh-panel .it.folder.drop-in { position: relative }
|
|
517
|
-
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left:
|
|
529
|
+
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: var(--seam-left, 30px); right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
|
|
518
530
|
background: #0088ff; pointer-events: none }
|
|
519
531
|
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
520
532
|
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|
|
@@ -587,6 +599,26 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
587
599
|
.sh-menu .chk { color: var(--glass-accent) }
|
|
588
600
|
.sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
|
|
589
601
|
.sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
|
|
602
|
+
/* the board status picker (StatusPicker.tsx): opens in place of the right-click menu - a filter,
|
|
603
|
+
what the evidence says now, the statuses a person may set, numbered, and a line on the rest */
|
|
604
|
+
.sh-ctxpanel { width: 248px; max-width: 248px }
|
|
605
|
+
.sh-status-picker { display: flex; flex-direction: column }
|
|
606
|
+
.sh-status-picker .sp-input { height: 32px; margin: 0 0 4px; padding: 0 10px; border: 0; border-bottom: 1px solid var(--glass-brd);
|
|
607
|
+
border-radius: 0; background: none; color: var(--glass-ink); font: 500 12.5px -apple-system, system-ui, sans-serif; outline: 0 }
|
|
608
|
+
.sh-status-picker .sp-input::placeholder { color: var(--glass-ink-3) }
|
|
609
|
+
.sh-status-picker .sp-now { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; margin-bottom: 2px;
|
|
610
|
+
font: 500 12px -apple-system, system-ui, sans-serif; color: var(--glass-ink) }
|
|
611
|
+
.sh-status-picker .sp-now small { margin-left: auto; color: var(--glass-ink-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px }
|
|
612
|
+
.sh-status-picker .sp-list { display: flex; flex-direction: column }
|
|
613
|
+
.sh-status-picker .sp-list button { width: 100% }
|
|
614
|
+
.sh-status-picker .sp-list button.hi { background: var(--glass-hover) }
|
|
615
|
+
.sh-status-picker .sp-list button span { text-transform: none }
|
|
616
|
+
.sh-status-picker .sp-chk { color: var(--glass-ink); flex: none }
|
|
617
|
+
.sh-status-picker .sp-head { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; font: 600 12px -apple-system, system-ui, sans-serif; color: var(--glass-ink) }
|
|
618
|
+
.sh-status-picker .sp-empty { padding: 8px 10px; color: var(--glass-ink-3); font: 500 12px -apple-system, system-ui, sans-serif }
|
|
619
|
+
.sh-status-picker .sp-foot { border-top: 1px solid var(--glass-brd); margin-top: 4px; padding: 7px 10px 3px; color: var(--glass-ink-3);
|
|
620
|
+
font: 500 11px/1.35 -apple-system, system-ui, sans-serif }
|
|
621
|
+
.sh-status-picker[data-status-picker="reason"] .sp-input { border: 1px solid var(--glass-brd); border-radius: 9px; margin: 2px 6px 0; height: 30px }
|
|
590
622
|
|
|
591
623
|
/* shadcn-style tooltip: contrast-flipped solid, zoom-fade in */
|
|
592
624
|
.sh-tip { position: absolute; z-index: 400; pointer-events: none; padding: 5px 10px; border-radius: 8px; /* topmost ephemeral chrome - above modals (300) or in-dialog tips vanish */
|