A review desk panel (/reviews): Claude publishes open questions, decisions, findings, write plans, animated explainers and read-throughs; you answer, ask and…

A review desk inside Claude Code. When Claude needs you to answer questions, approve decisions, accept or reject findings, approve a write plan, walk through an explainer or read a long text, it puts the work on the desk. You open the panel (/reviews), work through the cards, ask questions as you go and submit. Claude gets the outcome back and applies it.
There are no HTML pages to build, no browser to open and no notes to export and paste back. It is made for projects that pair a Claude project folder with a record of truth, such as an Obsidian vault, but it works in any project.
| Card | You |
|---|---|
| Question | pick an option (Claude's recommended one is marked) or write an answer |
| Decision | approve, approve with changes, or reject, with a comment |
| Finding | accept, reject or discuss, with a reply; quotes, severity and "the record says" evidence are shown |
| Plan | untick rows you do not want, then go ahead or cancel |
| Explainer | step through animated scenes with captions and graded claims, ticking Got it |
| Read-through | read a long text section by section, with "Your call" flags, a note and a Reviewed tick each |
draft.json straight away. Close the panel, or end the session, and carry on later.outcome.md and outcome.json, moves the review to reviewed/, and queues a prompt that asks Claude to apply the outcome in each record's real home and to confirm with you before writing.At the prompt of a Claude Code terminal session:
/plugin install review-desk --marketplace marwansaab/Claude-Mods
Answer y to add the marketplace, then choose a scope. A mod installed this way also loads in the desktop app's Code tab.
| Name | What it does |
|---|---|
/reviews | Opens the review desk, or closes it when it is open |
publish_review (tool for Claude) | Puts a review on the desk. Claude is told to use it instead of building HTML review pages or asking many questions in chat |
list_reviews (tool for Claude) | Lists pending and submitted reviews, with the path of each outcome |
answer_question (tool for Claude) | Puts Claude's answer to a question you sent from a card back on that card, with its sources |
To start, ask Claude something like "put the open questions for this initiative up for review", "send this write plan to the review desk" or "make an explainer of this note on the review desk".
| Setting | Default | What it does |
|---|---|---|
| Reviews folder | Reviews | The folder in the project, relative to its root, that holds pending/ and reviewed/ |
| Open the desk when Claude publishes a review | on | Opens the panel on the new review, unless you are in the middle of another one |
| Hand submitted reviews to Claude | on | On submit, queues a prompt asking Claude to apply the outcome |
| Selectable text | on | On the desktop, draws card text so it can be selected; off draws it as the app's own text, with every link clickable but nothing selectable |
<project>/Reviews/
README.md what the folder is
pending/<id>/pack.json the review as Claude published it
pending/<id>/draft.json your answers so far
reviewed/<id>/ the same, plus outcome.md and outcome.json
publish_review. The desk checks the pack and refuses it with a list of what to fix when something is missing. For example, it refuses a card without its text, or a claim that is cited but not listed. Publishing the same id again replaces the pack and keeps your answers for the cards whose ids stayed the same.$.state so it survives the mod reloading.reviewed/<id>/ (or <id>--2 when that id was reviewed before). If the move fails, for example because a file is open in another program, the files are copied instead and the pending copy is hidden.publish_review takes title, cards and optionally id, label, summary, origin, sources (the notes it mirrors, with their updated dates) and claims (id, text, grade A–D, source, url, checked). The full schema is in hooks/pack.ts (PUBLISH_SCHEMA), and the types are in types/index.d.ts.
{
"id": "INIT-002-open-questions",
"title": "Open questions for the blackjack prototype",
"label": "Open questions",
"cards": [
{
"type": "question", "id": "Q-13", "title": "Report an inappropriate ad",
"prompt": "Should players be able to report an ad from the ad itself?",
"options": [{ "id": "yes", "label": "Yes" }, { "id": "later", "label": "Not in the first release" }],
"recommended": "yes"
},
{
"type": "explainer", "id": "retention", "title": "Why players leave",
"steps": [{
"title": "The drop", "caption": "Most players leave in the first week.",
"scene": { "kind": "bars", "items": [{ "label": "Day 1", "value": 40, "display": "40%", "claim": "C1" }], "mark": { "value": 20, "label": "target" } }
}]
}
],
"claims": [{ "id": "C1", "text": "Day-1 retention is about 40%.", "grade": "B", "source": "Analytics firm report", "url": "https://…" }]
}
Set-Clipboard on Windows, pbcopy on macOS, wl-copy or xclip on Linux.hooks/register.tsx 583 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ask, Draft, DeskView, OpenPack, Pack, PackStatus, PackSummary, Response } from '../types'
5import { FILES, README, isWindowsPath, join, moveArgv, submittedAtOf } from './disk'
6import {
7 ANSWER_DESCRIPTION,
8 ANSWER_SCHEMA,
9 PUBLISH_DESCRIPTION,
10 PUBLISH_SCHEMA,
11 cleanPack,
12 handOffPrompt,
13 outcomeJson,
14 outcomeMarkdown,
15 readDraft,
16 readPack,
17 summaryOf,
18} from './pack'
19import { homeView, packView } from './view'
20import type { Actions } from './view'
21
22const PANE = 'review-desk'
23const PANE_TITLE = 'Review desk'
24const TOOL = 'mcp__review-desk__publish_review'
25const LIST_TOOL = 'mcp__review-desk__list_reviews'
26const ANSWER_TOOL = 'mcp__review-desk__answer_question'
27
28const START_VIEW: DeskView = {
29 tab: 'pending',
30 card: 0,
31 part: {},
32 isSummaryOpen: false,
33 isConfirming: false,
34 askOpen: {},
35 showAllAsks: false,
36}
37
38const packs = atom({ plugin: 'review-desk', key: 'packs' } as const, [] as PackSummary[])
39const view = atom({ plugin: 'review-desk', key: 'view' } as const, START_VIEW)
40const open = atom({ plugin: 'review-desk', key: 'open' } as const, null as OpenPack | null)
41const typing = atom({ plugin: 'review-desk', key: 'typing' } as const, {} as Record<string, string>)
42const rootRef = atom({ plugin: 'review-desk', key: 'root' } as const, '')
43
44/** A field's address less its redraw counter (`ask:Q-1#2` is the question field of card Q-1). */
45const fieldKey = (key: string) => key.replace(/#\d+$/, '')
46
47/** The view as stored, with any field an older version of the mod did not keep filled in. */
48function viewOf(v: Partial<DeskView> | undefined): DeskView {
49 return { ...START_VIEW, ...v, part: v?.part ?? {}, askOpen: v?.askOpen ?? {} }
50}
51
52/** The person's settings: set by `register` on every load, read by the functions below. */
53const cfg = { folder: 'Reviews', autoOpen: true, handOff: true, selectableText: true }
54
55/** The desk observes and stores; nothing it does may fail a tool call, a command or a turn. */
56async function safely<T>(work: () => Promise<T>): Promise<T | undefined> {
57 try {
58 return await work()
59 } catch {
60 return undefined
61 }
62}
63
64async function nowIso($: EngineInterface): Promise<string> {
65 return new Date(await $.clock.now()).toISOString()
66}
67
68// ---------- the Reviews folder ----------
69
70/** The Reviews folder of the session's project. */
71async function rootOf($: EngineInterface): Promise<string> {
72 const known = await read($, rootRef)
73 if (known) return known
74 const root = join(await $.session.root(), cfg.folder)
75 await update($, rootRef, () => root)
76 return root
77}
78
79async function readText($: EngineInterface, path: string): Promise<string | undefined> {
80 try {
81 return await $.fs.read(path)
82 } catch {
83 return undefined
84 }
85}
86
87async function dirsOf($: EngineInterface, path: string): Promise<string[]> {
88 try {
89 if (!(await $.fs.exists(path))) return []
90 return (await $.fs.list(path)).filter(e => e.kind === 'dir' && !e.name.startsWith('.')).map(e => e.name)
91 } catch {
92 return []
93 }
94}
95
96/** Every pack, newest first: pending ones not yet submitted, then the reviewed ones. */
97async function listPacks($: EngineInterface, root: string): Promise<PackSummary[]> {
98 const out: PackSummary[] = []
99 for (const status of ['pending', 'reviewed'] as PackStatus[]) {
100 for (const name of await dirsOf($, join(root, status))) {
101 const dir = join(root, status, name)
102 const packText = await readText($, join(dir, FILES.pack))
103 if (packText === undefined) continue
104 const pack = readPack(packText)
105 if (!pack) {
106 out.push({ id: name, title: `${name} (pack.json does not parse)`, status, createdAt: '', cards: 0, answered: 0, types: [], isBroken: true })
107 continue
108 }
109 const draft = readDraft(await readText($, join(dir, FILES.draft)))
110 const submittedAt = submittedAtOf(await readText($, join(dir, FILES.outcomeJson)))
111 // A pending folder left behind by a submit whose move failed: its copy is in reviewed/.
112 if (status === 'pending' && submittedAt && submittedAt >= pack.createdAt) continue
113 out.push({ ...summaryOf(pack, draft, status, submittedAt), id: name })
114 }
115 }
116 const at = (p: PackSummary) => p.submittedAt ?? p.createdAt
117 return out.sort((a, b) => (a.status === b.status ? at(b).localeCompare(at(a)) : a.status === 'pending' ? -1 : 1))
118}
119
120async function loadPack($: EngineInterface, root: string, status: PackStatus, id: string): Promise<OpenPack | undefined> {
121 const dir = join(root, status, id)
122 const text = await readText($, join(dir, FILES.pack))
123 const pack = text === undefined ? undefined : readPack(text)
124 if (!pack) return undefined
125 const draft = readDraft(await readText($, join(dir, FILES.draft)))
126 const submittedAt = submittedAtOf(await readText($, join(dir, FILES.outcomeJson)))
127 return { pack, draft, status, submittedAt }
128}
129
130async function saveDraft($: EngineInterface, root: string, id: string, draft: Draft, status: PackStatus = 'pending'): Promise<void> {
131 await $.fs.write(join(root, status, id, FILES.draft), JSON.stringify(draft, null, 2))
132}
133
134/**
135 * Writes a pack to pending/<id>/. A pack published again under its id keeps the owner's answers
136 * for the cards (and their steps and sections) that are still there.
137 */
138async function publishPack($: EngineInterface, root: string, pack: Pack): Promise<{ path: string; kept: number }> {
139 const readme = join(root, 'README.md')
140 if (!(await $.fs.exists(readme))) await $.fs.write(readme, README)
141 const dir = join(root, 'pending', pack.id)
142 const old = readDraft(await readText($, join(dir, FILES.draft)))
143 const ids = new Set(pack.cards.map(c => c.id))
144 const responses = Object.fromEntries(Object.entries(old.responses).filter(([key]) => ids.has(key.split('/')[0] ?? '')))
145 await $.fs.write(join(dir, FILES.pack), JSON.stringify(pack, null, 2))
146 await $.fs.write(join(dir, FILES.draft), JSON.stringify({ ...old, responses, updatedAt: pack.createdAt }, null, 2))
147 return { path: dir, kept: Object.keys(responses).length }
148}
149
150/** A free folder name under reviewed/: the id, or the id with a counter when it was reviewed before. */
151async function reviewedSlot($: EngineInterface, root: string, id: string): Promise<string> {
152 for (let n = 1; n < 1000; n++) {
153 const name = n === 1 ? id : `${id}--${n}`
154 if (!(await $.fs.exists(join(root, 'reviewed', name)))) return name
155 }
156 return `${id}--${await $.clock.now()}`
157}
158
159async function moveDir($: EngineInterface, from: string, to: string): Promise<boolean> {
160 try {
161 const run = await $.process.run(moveArgv(from, to), { timeoutMs: 20_000 })
162 return run.exitCode === 0 && (await $.fs.exists(join(to, FILES.pack)))
163 } catch {
164 return false
165 }
166}
167
168/**
169 * Submits a pending pack: writes the outcome beside it, then moves the folder to reviewed/.
170 * Where the move fails the files are copied instead, and the pending folder is hidden by its outcome.
171 */
172async function submitPack($: EngineInterface, root: string, id: string, current: OpenPack, submittedAt: string): Promise<{ outcomePath: string; slot: string; isMoved: boolean }> {
173 const from = join(root, 'pending', id)
174 const md = outcomeMarkdown(current.pack, current.draft, submittedAt)
175 const json = JSON.stringify(outcomeJson(current.pack, current.draft, submittedAt), null, 2)
176 const draft = JSON.stringify(current.draft, null, 2)
177 await $.fs.write(join(from, FILES.draft), draft)
178 await $.fs.write(join(from, FILES.outcome), md)
179 await $.fs.write(join(from, FILES.outcomeJson), json)
180
181 const slot = await reviewedSlot($, root, id)
182 const to = join(root, 'reviewed', slot)
183 await $.fs.write(join(root, 'reviewed', '.keep'), '')
184 const isMoved = await moveDir($, from, to)
185 if (!isMoved) {
186 await $.fs.write(join(to, FILES.pack), JSON.stringify(current.pack, null, 2))
187 await $.fs.write(join(to, FILES.draft), draft)
188 await $.fs.write(join(to, FILES.outcome), md)
189 await $.fs.write(join(to, FILES.outcomeJson), json)
190 }
191 return { outcomePath: join(to, FILES.outcome), slot, isMoved }
192}
193
194// ---------- the open review ----------
195
196async function refresh($: EngineInterface): Promise<PackSummary[]> {
197 const root = await rootOf($)
198 const list = await listPacks($, root)
199 await update($, packs, () => list)
200 const waiting = list.filter(p => p.status === 'pending').length
201 $.ui.status(waiting ? `${waiting} to review · /reviews` : undefined)
202 return list
203}
204
205async function changeDraft($: EngineInterface, fn: (d: OpenPack['draft']) => OpenPack['draft']): Promise<void> {
206 const at = await nowIso($)
207 const current = await read($, open)
208 if (!current || current.status !== 'pending') return
209 const draft = { ...fn(current.draft), updatedAt: at }
210 await update($, open, o => (o ? { ...o, draft } : o))
211 const v = viewOf(await read($, view))
212 if (!v.openId) return
213 const root = await rootOf($)
214 await safely(() => saveDraft($, root, v.openId!, draft))
215 const answered = summaryOf(current.pack, draft, 'pending').answered
216 await update($, packs, list => list.map(p => (p.status === 'pending' && p.id === v.openId ? { ...p, answered } : p)))
217}
218
219/** Writes what the person typed without pressing Enter into the draft, before anything else moves. */
220async function flushTyping($: EngineInterface): Promise<void> {
221 const typed = await read($, typing)
222 const keys = Object.keys(typed).filter(k => k.startsWith('t:'))
223 if (!keys.length) return
224 await update($, typing, t => Object.fromEntries(Object.entries(t).filter(([k]) => !k.startsWith('t:'))))
225 await changeDraft($, d => {
226 const responses = { ...d.responses }
227 for (const k of keys) responses[k.slice(2)] = { ...responses[k.slice(2)], text: typed[k] }
228 return { ...d, responses }
229 })
230}
231
232async function respond($: EngineInterface, key: string, patch: Partial<Response>): Promise<void> {
233 await flushTyping($)
234 await changeDraft($, d => ({ ...d, responses: { ...d.responses, [key]: { ...d.responses[key], ...patch } } }))
235}
236
237async function recordAsk($: EngineInterface, a: Ask): Promise<void> {
238 const current = await read($, open)
239 if (!current) return
240 if (current.status === 'pending') await changeDraft($, d => ({ ...d, asks: [...d.asks.filter(x => x.id !== a.id), a] }))
241 else await update($, open, o => (o ? { ...o, draft: { ...o.draft, asks: [...o.draft.asks.filter(x => x.id !== a.id), a] } } : o))
242}
243
244/** A question about the open card: sent to Claude in the session, whose answer comes back onto the card. */
245async function ask($: EngineInterface, question: string): Promise<void> {
246 const q = question.trim()
247 const current = await read($, open)
248 const v = viewOf(await read($, view))
249 if (!q || !current) return
250 const card = current.pack.cards[Math.min(v.card, current.pack.cards.length - 1)]
251 if (!card) return
252 const part =
253 card.type === 'explainer' ? card.steps[v.part[card.id] ?? 0]?.title : card.type === 'read' ? card.sections[v.part[card.id] ?? 0]?.title : undefined
254 const about = [card.title, part].filter(Boolean).join(': ')
255 const at = await $.clock.now()
256 const entry: Ask = { id: `a${at.toString(36)}`, question: q, card: card.id, about, at: new Date(at).toISOString(), via: 'session' }
257 await recordAsk($, entry)
258 const root = await rootOf($)
259 const folder = v.openId ?? current.pack.id
260 const where = join(root, current.status, folder, 'pack.json')
261 await $.prompt.submit({
262 text: [
263 `A question from the review desk, on ${card.type} card ${card.id} "${about}" of the review "${current.pack.title}" (${where}):`,
264 '',
265 q,
266 '',
267 `Research it with whatever you need: the web, this project, its notes. Then put your answer on the card with answer_question (review "${folder}", ask "${entry.id}"), in Markdown short enough for a side panel, and list what you drew on as sources with their URLs. Do not change any record unless I ask.`,
268 ].join('\n'),
269 })
270}
271
272async function submit($: EngineInterface): Promise<void> {
273 await flushTyping($)
274 const current = await read($, open)
275 const v = viewOf(await read($, view))
276 if (!current || current.status !== 'pending' || !v.openId) return
277 const root = await rootOf($)
278 const at = await nowIso($)
279 const done = await safely(() => submitPack($, root, v.openId!, current, at))
280 if (!done) {
281 $.ui.toast('Could not save the outcome. Nothing was submitted.')
282 return
283 }
284 await update($, open, () => null)
285 await update($, view, x => ({ ...START_VIEW, tab: x.tab }))
286 await refresh($)
287 $.ui.toast(cfg.handOff ? 'Review submitted and handed to Claude' : 'Review submitted')
288 if (cfg.handOff) await safely(() => $.prompt.submit({ text: handOffPrompt(current.pack, done.outcomePath) }))
289}
290
291async function openPack($: EngineInterface, status: DeskView['tab'], id: string): Promise<void> {
292 const loaded = await loadPack($, await rootOf($), status, id)
293 await update($, open, () => loaded ?? null)
294 // The summary opens on a review not yet started; after that it stays out of the way.
295 const isFresh = !!loaded && Object.keys(loaded.draft.responses).length === 0 && status === 'pending'
296 await update($, view, x => ({ ...START_VIEW, tab: x.tab, openId: id, openStatus: status, isSummaryOpen: isFresh && !!loaded?.pack.summary }))
297}
298
299async function reloadOpen($: EngineInterface): Promise<void> {
300 await refresh($)
301 const v = viewOf(await read($, view))
302 if (!v.openId || !v.openStatus) return
303 const loaded = await loadPack($, await rootOf($), v.openStatus, v.openId)
304 if (loaded) await update($, open, () => loaded)
305}
306
307async function closePack($: EngineInterface): Promise<void> {
308 await flushTyping($)
309 await update($, open, () => null)
310 await update($, view, x => ({ ...START_VIEW, tab: x.tab }))
311 await refresh($)
312}
313
314async function moveTo($: EngineInterface, patch: (v: DeskView) => DeskView): Promise<void> {
315 await flushTyping($)
316 await update($, view, patch)
317}
318
319async function toggleRow($: EngineInterface, cardId: string, rowId: string): Promise<void> {
320 await flushTyping($)
321 await changeDraft($, d => {
322 const r = d.responses[cardId] ?? {}
323 const rows = { ...r.rows, [rowId]: !(r.rows?.[rowId] ?? true) }
324 return { ...d, responses: { ...d.responses, [cardId]: { ...r, rows } } }
325 })
326}
327
328async function commitField($: EngineInterface, key: string, text: string): Promise<void> {
329 await update($, typing, t => Object.fromEntries(Object.entries(t).filter(([k]) => k !== key)))
330 if (key.startsWith('ask:')) await ask($, text)
331 else if (key.startsWith('t:')) await respond($, key.slice(2), { text })
332}
333
334async function resend($: EngineInterface): Promise<void> {
335 const current = await read($, open)
336 const v = viewOf(await read($, view))
337 if (!current || !v.openId) return
338 const root = await rootOf($)
339 await $.prompt.submit({ text: handOffPrompt(current.pack, join(root, 'reviewed', v.openId, 'outcome.md')) })
340 $.ui.toast('Outcome sent to Claude')
341}
342
343/**
344 * Puts text on the clipboard: the surface's own first; where it has no path (a remote surface such as
345 * the desktop app), the system's clipboard command on this machine.
346 */
347async function copyText($: EngineInterface, text: string, surface: string): Promise<void> {
348 const viaSurface = await $.ui.copy({ text, surface: surface as never }).catch(() => ({ isCopied: false as const }))
349 if (viaSurface.isCopied) {
350 $.ui.toast('Copied')
351 return
352 }
353 const root = await rootOf($)
354 const tries: string[][] = isWindowsPath(root)
355 ? [['powershell', '-NoProfile', '-NonInteractive', '-Command', '[Console]::InputEncoding=[Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())']]
356 : [['pbcopy'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]
357 for (const argv of tries) {
358 const run = await $.process.run(argv, { stdin: text, timeoutMs: 10_000 }).catch(() => undefined)
359 if (run?.exitCode === 0) {
360 $.ui.toast('Copied')
361 return
362 }
363 }
364 $.ui.toast('Could not reach the clipboard')
365}
366
367/** The pane's controls, each one saving what it changes. */
368function actionsFor($: EngineInterface): Actions {
369 const run = (work: () => Promise<unknown>) => void safely(work)
370 return {
371 tab: tab => run(() => update($, view, x => ({ ...x, tab }))),
372 refresh: () => run(() => reloadOpen($)),
373 openPack: (status, id) => run(() => openPack($, status, id)),
374 closePack: () => run(() => closePack($)),
375 goCard: n => run(() => moveTo($, x => ({ ...x, card: Math.max(0, n), isConfirming: false, showAllAsks: false }))),
376 goPart: (cardId, n) => run(() => moveTo($, x => ({ ...x, part: { ...x.part, [cardId]: Math.max(0, n) } }))),
377 respond: (key, patch) => run(() => respond($, key, patch)),
378 toggleRow: (cardId, rowId) => run(() => toggleRow($, cardId, rowId)),
379 type: (key, text) => run(() => update($, typing, t => ({ ...t, [fieldKey(key)]: text }))),
380 commit: (key, text) => run(() => commitField($, fieldKey(key), text)),
381 toggleSummary: () => run(() => update($, view, x => ({ ...x, isSummaryOpen: !x.isSummaryOpen }))),
382 showAllAsks: () => run(() => update($, view, x => ({ ...x, showAllAsks: true }))),
383 submit: () => run(() => submit($)),
384 confirm: isOn => run(() => moveTo($, x => ({ ...x, isConfirming: isOn }))),
385 resend: () => run(() => resend($)),
386 copy: (text, surface) => run(() => copyText($, text, surface)),
387 }
388}
389
390async function openPane($: EngineInterface): Promise<void> {
391 await refresh($)
392 await $.ui.open({ id: PANE, title: PANE_TITLE })
393}
394
395async function setUp($: EngineInterface): Promise<void> {
396 // Each piece on its own: a refused command must not keep the tools from Claude, nor the other way round.
397 const steps: [string, () => Promise<unknown>][] = [
398 ['the publish_review tool', () => $.tool.register({ name: 'publish_review', description: PUBLISH_DESCRIPTION, inputSchema: PUBLISH_SCHEMA, isDeferred: false })],
399 [
400 'the list_reviews tool',
401 () =>
402 $.tool.register({
403 name: 'list_reviews',
404 description:
405 "List the review desk's reviews (pending and submitted) with their folders and how many cards the owner has answered. Outcomes are in <folder>/reviewed/<id>/outcome.md.",
406 inputSchema: { type: 'object', properties: {} },
407 }),
408 ],
409 [
410 'the answer_question tool',
411 () =>
412 $.tool.register({
413 name: 'answer_question',
414 description: ANSWER_DESCRIPTION,
415 inputSchema: ANSWER_SCHEMA,
416 }),
417 ],
418 [
419 'the /reviews command',
420 () =>
421 $.command.register({
422 name: 'reviews',
423 description: 'Open the review desk: reviews waiting for you (questions, decisions, findings, plans, explainers, read-throughs) and the ones you submitted',
424 }),
425 ],
426 ['the reviews list', async () => {
427 await update($, rootRef, () => '')
428 await refresh($)
429 }],
430 ]
431 for (const [what, step] of steps) {
432 try {
433 await step()
434 } catch (err) {
435 $.ui.log(`review-desk could not set up ${what}: ${err instanceof Error ? err.message : String(err)}`)
436 }
437 }
438}
439
440async function publish($: EngineInterface, input: Record<string, unknown>) {
441 const at = await nowIso($)
442 const { pack, problems } = cleanPack(input, at)
443 if (!pack) return { deny: `The review was not published. Fix these and call publish_review again:\n- ${problems.join('\n- ')}` }
444 const root = await rootOf($)
445 const { path, kept } = await publishPack($, root, pack)
446 await safely(async () => {
447 await refresh($)
448 if (!cfg.autoOpen) return
449 const v = viewOf(await read($, view))
450 if (!v.openId || v.openId === pack.id) {
451 const loaded = await loadPack($, root, 'pending', pack.id)
452 await update($, open, () => loaded ?? null)
453 await update($, view, x => ({
454 ...START_VIEW,
455 tab: 'pending' as const,
456 openId: pack.id,
457 openStatus: 'pending' as const,
458 isSummaryOpen: !!pack.summary,
459 card: x.openId === pack.id ? x.card : 0,
460 part: x.openId === pack.id ? x.part : {},
461 }))
462 }
463 await $.ui.open({ id: PANE, title: PANE_TITLE })
464 })
465 const byType = pack.cards.reduce<Record<string, number>>((m, c) => ({ ...m, [c.type]: (m[c.type] ?? 0) + 1 }), {})
466 const counts = Object.entries(byType)
467 .map(([t, n]) => `${n} ${t}`)
468 .join(', ')
469 return {
470 result: [
471 `Published "${pack.title}" as ${pack.id}: ${pack.cards.length} cards (${counts}), saved in ${path}.`,
472 kept ? `Kept ${kept} of the owner's earlier answers for cards with the same ids.` : '',
473 'The owner reviews it in the review desk panel (/reviews). Do not also build an HTML page for it, and do not repeat its questions in chat. When the owner submits, a prompt points you at the outcome; until then, carry on or end your turn.',
474 ]
475 .filter(Boolean)
476 .join('\n'),
477 }
478}
479
480/** Claude's answer to a question the owner sent from a card: written into that card's thread, on disk and on screen. */
481async function answerQuestion($: EngineInterface, input: Record<string, unknown>) {
482 const review = typeof input.review === 'string' ? input.review.trim() : ''
483 const askId = typeof input.ask === 'string' ? input.ask.trim() : ''
484 const answer = typeof input.answer === 'string' ? input.answer.trim() : ''
485 if (!review || !askId || !answer) return { deny: 'answer_question needs "review", "ask" and "answer", as given in the question prompt.' }
486 const root = await rootOf($)
487 let status: PackStatus | undefined
488 for (const s of ['pending', 'reviewed'] as PackStatus[]) if (!status && (await $.fs.exists(join(root, s, review, FILES.pack)))) status = s
489 if (!status) return { deny: `No review "${review}" in ${root}. Use the folder name from the question prompt, or list_reviews.` }
490
491 const current = await read($, open)
492 const v = viewOf(await read($, view))
493 const isOnScreen = !!current && v.openId === review && current.status === status
494 const draft = isOnScreen && current ? current.draft : readDraft(await readText($, join(root, status, review, FILES.draft)))
495 const asked = draft.asks.find(a => a.id === askId)
496 if (!asked) return { deny: `Review "${review}" has no question "${askId}". Use the ask id from the question prompt.` }
497
498 const sources = (Array.isArray(input.sources) ? input.sources : [])
499 .filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
500 .map(x => ({ title: String(x.title ?? x.url ?? '').trim(), url: typeof x.url === 'string' ? x.url.trim() : undefined }))
501 .filter(x => x.title)
502 const done: Ask = { ...asked, via: 'session', answer, answeredAt: await nowIso($), isError: false, sources: sources.length ? sources : undefined }
503 const next = { ...draft, asks: draft.asks.map(a => (a.id === askId ? done : a)) }
504 await saveDraft($, root, review, next, status)
505 if (isOnScreen) {
506 await update($, open, o => (o ? { ...o, draft: next } : o))
507 const cardId = asked.card
508 if (cardId) await update($, view, x => ({ ...viewOf(x), askOpen: { ...(x.askOpen ?? {}), [cardId]: true } }))
509 }
510 $.ui.toast(`Claude answered on ${asked.about ?? 'the card'}`)
511 return { result: `Answer saved on ${asked.card ? `card ${asked.card}` : 'the card'} of "${review}". The owner sees it in the review desk; there is no need to repeat it in full here.` }
512}
513
514async function listForClaude($: EngineInterface) {
515 const list = await refresh($)
516 const root = await rootOf($)
517 if (!list.length) return { result: `No reviews yet in ${root}.` }
518 const lines = list.map(
519 p =>
520 `- [${p.status}] ${p.id}: ${p.title} · ${p.answered}/${p.cards} answered${p.submittedAt ? ` · submitted ${p.submittedAt} · ${join(root, 'reviewed', p.id, 'outcome.md')}` : ''}`,
521 )
522 return { result: `Reviews in ${root}:\n${lines.join('\n')}` }
523}
524
525export const register: Register = (on, options) => {
526 cfg.folder = typeof options.folder === 'string' && options.folder.trim() ? options.folder.trim().replace(/[\\/]+$/, '') : 'Reviews'
527 cfg.autoOpen = options.autoOpen !== false
528 cfg.handOff = options.handOff !== false
529 cfg.selectableText = options.selectableText !== false
530
531 on('session.start', async ($, e, next) => {
532 const started = await next(e)
533 await safely(() => setUp($))
534 return started
535 })
536
537 on('command.run', { command: 'reviews' }, async $ => {
538 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
539 if (isOpen) {
540 await $.ui.close({ id: PANE })
541 return { text: 'Review desk closed.' }
542 }
543 await openPane($)
544 const waiting = (await read($, packs)).filter(p => p.status === 'pending').length
545 return { text: waiting ? `Review desk: ${waiting} waiting for you.` : 'Review desk: nothing waiting.' }
546 }).catch(($, e, next) => next(e))
547
548 on('tool.call', { tool: TOOL }, async ($, e) => publish($, e as unknown as Record<string, unknown>)).catch(($, e, next) => next(e))
549
550 on('tool.call', { tool: LIST_TOOL }, async $ => listForClaude($)).catch(($, e, next) => next(e))
551
552 on('tool.call', { tool: ANSWER_TOOL }, async ($, e) => answerQuestion($, e as unknown as Record<string, unknown>)).catch(($, e, next) => next(e))
553
554 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
555 const ui = $.ui.resolve(e)
556 const v = viewOf(await read($, view))
557 const ctx = {
558 ui,
559 isDesktop: e.surface !== 'terminal' && 'Svg' in ui,
560 width: Math.max(280, Math.min(920, (e.props.bodyColumns || 48) * 8 - 8)),
561 packs: await read($, packs),
562 view: v,
563 open: v.openId ? await read($, open) : null,
564 act: actionsFor($),
565 folder: cfg.folder,
566 selectable: cfg.selectableText,
567 }
568 try {
569 return v.openId ? packView(ctx) : homeView(ctx)
570 } catch (err) {
571 // Never leave the pane blank: say what failed and offer the way back to the list.
572 const { Box, Text, Button } = ui
573 return (
574 <Box flexDirection="column" gap={1}>
575 <Text bold>The review desk could not draw this view.</Text>
576 <Text dimColor>{err instanceof Error ? err.message : String(err)}</Text>
577 <Button key="recover" label="Back to all reviews" onPress={() => void safely(() => closePack($))} />
578 </Box>
579 )
580 }
581 })
582}
583hooks/disk.ts 34 lines1// The Reviews/ folder's layout: pending/<id>/ and reviewed/<id>/, each holding pack.json, draft.json
2// and, once submitted, outcome.md and outcome.json. The file system calls live in register.tsx.
3
4export const FILES = { pack: 'pack.json', draft: 'draft.json', outcome: 'outcome.md', outcomeJson: 'outcome.json' }
5
6export const README = `# Reviews
7
8Review packs for the owner, written by Claude through the review-desk mod and answered in its panel (\`/reviews\`).
9
10- \`pending/<id>/pack.json\`: what is up for review; \`draft.json\`: the owner's answers so far.
11- \`reviewed/<id>/\`: the same, plus \`outcome.md\` and \`outcome.json\` once submitted.
12
13These files mirror the project's records (files, notes, documents); they are never the record. Claude applies an outcome in the record's own home.
14`
15
16export const sepOf = (root: string) => (root.includes('\\') && !root.includes('/') ? '\\' : '/')
17export const join = (root: string, ...parts: string[]) => [root.replace(/[\\/]+$/, ''), ...parts].join(sepOf(root))
18export const isWindowsPath = (path: string) => sepOf(path) === '\\' || /^[A-Za-z]:/.test(path)
19
20/** The command that moves a folder, by the platform the path belongs to (no shell). */
21export const moveArgv = (from: string, to: string): string[] =>
22 isWindowsPath(from) ? ['cmd', '/d', '/c', 'move', '/y', from, to] : ['mv', from, to]
23
24/** `submittedAt` from an outcome.json, if it parses. */
25export function submittedAtOf(text: string | undefined): string | undefined {
26 if (!text) return undefined
27 try {
28 const at = JSON.parse(text).submittedAt
29 return typeof at === 'string' ? at : undefined
30 } catch {
31 return undefined
32 }
33}
34hooks/pack.ts 610 lines1// Review packs: validation of what Claude publishes, progress, and the outcome handed back.
2import type {
3 Ask,
4 Card,
5 CardType,
6 Claim,
7 Draft,
8 Pack,
9 PackSummary,
10 PackStatus,
11 Response,
12 Scene,
13 Tone,
14} from '../types'
15
16export const CARD_TYPES: readonly CardType[] = ['question', 'decision', 'finding', 'plan', 'explainer', 'read']
17const TONES: readonly Tone[] = ['subject', 'context', 'risk', 'question', 'decision', 'good']
18const SCENES = ['cards', 'flow', 'list', 'timeline', 'funnel', 'bars', 'tiles', 'compare', 'matrix']
19const MAX_CARDS = 80
20const MAX_PARTS = 60
21
22export const TYPE_LABEL: Record<CardType, string> = {
23 question: 'Question',
24 decision: 'Decision',
25 finding: 'Finding',
26 plan: 'Plan',
27 explainer: 'Explainer',
28 read: 'Read-through',
29}
30
31export const emptyDraft = (at = ''): Draft => ({ responses: {}, asks: [], updatedAt: at })
32
33// ---------- ids ----------
34
35/** A folder-safe id: letters, digits, `-` and `_`, at most 64. */
36export function safeId(raw: string): string {
37 return raw
38 .normalize('NFKD')
39 .replace(/[^\w\s-]/g, '')
40 .trim()
41 .replace(/[\s_]+/g, '-')
42 .replace(/-+/g, '-')
43 .replace(/^-|-$/g, '')
44 .slice(0, 64)
45}
46
47/** The id Claude gave, or one made from the title and the date. */
48export function packIdOf(id: unknown, title: string, isoNow: string): string {
49 const given = typeof id === 'string' ? safeId(id) : ''
50 if (given) return given
51 const day = isoNow.slice(0, 10)
52 return safeId(`${day}-${title}`) || `review-${day}`
53}
54
55// ---------- validation ----------
56
57type Problems = string[]
58const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
59const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined)
60const strList = (v: unknown): string[] | undefined =>
61 Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : undefined
62const toneOf = (v: unknown): Tone | undefined => (TONES.includes(v as Tone) ? (v as Tone) : undefined)
63
64function need(o: Record<string, unknown>, key: string, where: string, problems: Problems): string {
65 const v = str(o[key])
66 if (v === undefined) problems.push(`${where}: "${key}" is required text`)
67 return v ?? ''
68}
69
70function cleanScene(raw: unknown, where: string, problems: Problems): Scene | undefined {
71 if (raw === undefined || raw === null) return undefined
72 if (!isObj(raw) || !SCENES.includes(String(raw.kind))) {
73 problems.push(`${where}: scene.kind must be one of ${SCENES.join(', ')}`)
74 return undefined
75 }
76 const kind = raw.kind as Scene['kind']
77 const list = (key: string) => (Array.isArray(raw[key]) ? (raw[key] as unknown[]).filter(isObj) : [])
78 const item = (o: Record<string, unknown>) => ({
79 title: str(o.title) ?? str(o.label) ?? '',
80 text: str(o.text),
81 tone: toneOf(o.tone),
82 claim: str(o.claim),
83 })
84 switch (kind) {
85 case 'cards':
86 case 'flow':
87 case 'list':
88 case 'timeline': {
89 const items = list('items').map(item).filter(i => i.title)
90 if (!items.length) problems.push(`${where}: a ${kind} scene needs items with a title`)
91 return { kind, items: items.slice(0, 12) }
92 }
93 case 'funnel': {
94 const items = list('items').map(o => ({ label: str(o.label) ?? str(o.title) ?? '', value: str(o.value) ?? (typeof o.value === 'number' ? String(o.value) : undefined), tone: toneOf(o.tone), claim: str(o.claim) }))
95 return { kind, items: items.filter(i => i.label).slice(0, 6) }
96 }
97 case 'bars': {
98 const items = list('items')
99 .map(o => ({ label: str(o.label) ?? '', value: Number(o.value), display: str(o.display), tone: toneOf(o.tone), claim: str(o.claim) }))
100 .filter(i => i.label && Number.isFinite(i.value))
101 if (!items.length) problems.push(`${where}: a bars scene needs items with a label and a numeric value`)
102 const mark = isObj(raw.mark) && Number.isFinite(Number(raw.mark.value)) ? { value: Number(raw.mark.value), label: str(raw.mark.label) } : undefined
103 const max = Number.isFinite(Number(raw.max)) && Number(raw.max) > 0 ? Number(raw.max) : undefined
104 return { kind, items: items.slice(0, 10), max, mark }
105 }
106 case 'tiles': {
107 const items = list('items').map(o => ({ value: str(o.value) ?? (typeof o.value === 'number' ? String(o.value) : ''), label: str(o.label) ?? '', tone: toneOf(o.tone), claim: str(o.claim) }))
108 return { kind, items: items.filter(i => i.value || i.label).slice(0, 6) }
109 }
110 case 'compare': {
111 const columns = strList(raw.columns) ?? []
112 const rows = list('rows').map(o => ({ label: str(o.label) ?? '', cells: (Array.isArray(o.cells) ? o.cells : []).map(c => String(c ?? '')), tone: toneOf(o.tone) }))
113 if (!columns.length) problems.push(`${where}: a compare scene needs columns`)
114 return { kind, columns: columns.slice(0, 5), rows: rows.slice(0, 8) }
115 }
116 case 'matrix': {
117 const dots = list('dots')
118 .map(o => ({ label: str(o.label) ?? '', x: Number(o.x), y: Number(o.y), tone: toneOf(o.tone), claim: str(o.claim) }))
119 .filter(d => d.label && Number.isFinite(d.x) && Number.isFinite(d.y))
120 .map(d => ({ ...d, x: Math.min(1, Math.max(0, d.x)), y: Math.min(1, Math.max(0, d.y)) }))
121 return { kind, x: str(raw.x) ?? '', y: str(raw.y) ?? '', quadrants: strList(raw.quadrants)?.slice(0, 4), dots: dots.slice(0, 16) }
122 }
123 }
124}
125
126function cleanCard(raw: unknown, n: number, seen: Set<string>, problems: Problems): Card | undefined {
127 const where = `cards[${n}]`
128 if (!isObj(raw)) {
129 problems.push(`${where}: must be an object`)
130 return undefined
131 }
132 const type = raw.type as CardType
133 if (!CARD_TYPES.includes(type)) {
134 problems.push(`${where}: "type" must be one of ${CARD_TYPES.join(', ')}`)
135 return undefined
136 }
137 let id = safeId(str(raw.id) ?? '') || `${type[0]}${n + 1}`
138 while (seen.has(id)) id = `${id}-${n + 1}`
139 seen.add(id)
140 const title = need(raw, 'title', `${where} (${id})`, problems)
141 const claims = strList(raw.claims)
142 const at = `${where} (${id})`
143 const parts = (key: string) => (Array.isArray(raw[key]) ? (raw[key] as unknown[]).slice(0, MAX_PARTS) : [])
144
145 switch (type) {
146 case 'question': {
147 const options = parts('options')
148 .filter(isObj)
149 .map((o, i) => ({ id: safeId(str(o.id) ?? '') || `o${i + 1}`, label: str(o.label) ?? '', description: str(o.description) }))
150 .filter(o => o.label)
151 const recommended = str(raw.recommended)
152 return {
153 type, id, title, claims,
154 prompt: need(raw, 'prompt', at, problems),
155 context: str(raw.context),
156 options: options.length ? options : undefined,
157 recommended: options.some(o => o.id === recommended) ? recommended : undefined,
158 allowOther: raw.allowOther !== false,
159 }
160 }
161 case 'decision':
162 return {
163 type, id, title, claims,
164 proposal: need(raw, 'proposal', at, problems),
165 rationale: str(raw.rationale),
166 alternatives: parts('alternatives').filter(isObj).map(o => ({ label: str(o.label) ?? '', reason: str(o.reason) })).filter(o => o.label),
167 consequences: str(raw.consequences),
168 }
169 case 'finding': {
170 const evidence = isObj(raw.evidence) && str(raw.evidence.quote) ? { ref: str(raw.evidence.ref), quote: String(raw.evidence.quote) } : undefined
171 const severity = ['high', 'medium', 'low'].includes(String(raw.severity)) ? (raw.severity as 'high' | 'medium' | 'low') : undefined
172 const kind = raw.kind === 'drift' || raw.kind === 'review' ? raw.kind : undefined
173 return {
174 type, id, title, claims, severity, kind, evidence,
175 body: need(raw, 'body', at, problems),
176 where: str(raw.where),
177 quote: str(raw.quote),
178 suggestion: str(raw.suggestion),
179 }
180 }
181 case 'plan': {
182 const rows = parts('rows')
183 .filter(isObj)
184 .map((o, i) => ({ id: safeId(str(o.id) ?? '') || `r${i + 1}`, action: str(o.action) ?? 'change', target: str(o.target) ?? str(o.path) ?? '', summary: str(o.summary) }))
185 .filter(r => r.target)
186 if (!rows.length) problems.push(`${at}: a plan needs rows, each with a "target"`)
187 return { type, id, title, intro: str(raw.intro), rows }
188 }
189 case 'explainer': {
190 const steps = parts('steps')
191 .filter(isObj)
192 .map((o, i) => ({
193 id: safeId(str(o.id) ?? '') || `step-${i + 1}`,
194 title: str(o.title) ?? `Step ${i + 1}`,
195 caption: str(o.caption) ?? '',
196 scene: cleanScene(o.scene, `${at} step ${i + 1}`, problems),
197 claims: strList(o.claims),
198 }))
199 if (!steps.length) problems.push(`${at}: an explainer needs steps`)
200 return { type, id, title, intro: str(raw.intro), steps }
201 }
202 case 'read': {
203 const sections = parts('sections')
204 .filter(isObj)
205 .map((o, i) => ({ id: safeId(str(o.id) ?? '') || `s${i + 1}`, title: str(o.title) ?? `Section ${i + 1}`, body: typeof o.body === 'string' ? o.body : '', flags: strList(o.flags) }))
206 if (!sections.length) problems.push(`${at}: a read-through needs sections`)
207 return { type, id, title, intro: str(raw.intro), sections }
208 }
209 }
210}
211
212/** Checks and tidies what Claude sent; problems are returned for Claude to fix, never guessed past. */
213export function cleanPack(raw: unknown, isoNow: string): { pack?: Pack; problems: string[] } {
214 const problems: Problems = []
215 if (!isObj(raw)) return { problems: ['the input must be an object'] }
216 const title = need(raw, 'title', 'pack', problems)
217 const rawCards = Array.isArray(raw.cards) ? raw.cards : []
218 if (!rawCards.length) problems.push('pack: "cards" must list at least one card')
219 if (rawCards.length > MAX_CARDS) problems.push(`pack: at most ${MAX_CARDS} cards (split it into several reviews)`)
220 const seen = new Set<string>()
221 const cards = rawCards.slice(0, MAX_CARDS).map((c, i) => cleanCard(c, i, seen, problems)).filter((c): c is Card => !!c)
222 const claims: Claim[] = (Array.isArray(raw.claims) ? raw.claims : [])
223 .filter(isObj)
224 .map(o => ({
225 id: str(o.id) ?? '',
226 text: str(o.text) ?? '',
227 grade: ['A', 'B', 'C', 'D'].includes(String(o.grade)) ? (o.grade as Claim['grade']) : undefined,
228 source: str(o.source),
229 url: str(o.url),
230 checked: str(o.checked),
231 }))
232 .filter(c => c.id && c.text)
233 const known = new Set(claims.map(c => c.id))
234 const cited = cards.flatMap(c => [
235 ...('claims' in c ? c.claims ?? [] : []),
236 ...(c.type === 'explainer' ? c.steps.flatMap(s => [...(s.claims ?? []), ...sceneClaims(s.scene)]) : []),
237 ])
238 const missing = [...new Set(cited.filter(id => !known.has(id)))]
239 if (missing.length) problems.push(`claims cited but not listed in "claims": ${missing.join(', ')}`)
240 const sources = (Array.isArray(raw.sources) ? raw.sources : [])
241 .filter(isObj)
242 .map(o => ({ id: str(o.id), title: str(o.title), path: str(o.path), updated: str(o.updated) }))
243 .filter(s => s.id || s.title || s.path)
244 if (problems.length) return { problems }
245 return {
246 problems,
247 pack: {
248 schema: 1,
249 id: packIdOf(raw.id, title, isoNow),
250 title,
251 summary: str(raw.summary),
252 label: str(raw.label),
253 createdAt: isoNow,
254 origin: str(raw.origin),
255 sources: sources.length ? sources : undefined,
256 claims: claims.length ? claims : undefined,
257 cards,
258 },
259 }
260}
261
262export function sceneClaims(scene: Scene | undefined): string[] {
263 if (!scene) return []
264 const items: { claim?: string }[] =
265 scene.kind === 'compare' ? [] : scene.kind === 'matrix' ? scene.dots : (scene.items as { claim?: string }[])
266 return items.map(i => i.claim).filter((c): c is string => !!c)
267}
268
269/** A pack read back from disk: trusted to the extent that it parses into the shape. */
270export function readPack(text: string): Pack | undefined {
271 try {
272 const raw = JSON.parse(text)
273 if (!isObj(raw) || !Array.isArray(raw.cards) || typeof raw.title !== 'string') return undefined
274 const { pack } = cleanPack({ ...raw, id: raw.id }, typeof raw.createdAt === 'string' ? raw.createdAt : '')
275 return pack ? { ...pack, createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : pack.createdAt } : undefined
276 } catch {
277 return undefined
278 }
279}
280
281export function readDraft(text: string | undefined): Draft {
282 if (!text) return emptyDraft()
283 try {
284 const raw = JSON.parse(text)
285 return {
286 responses: isObj(raw.responses) ? (raw.responses as Record<string, Response>) : {},
287 asks: Array.isArray(raw.asks) ? (raw.asks as Ask[]) : [],
288 updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '',
289 }
290 } catch {
291 return emptyDraft()
292 }
293}
294
295// ---------- progress ----------
296
297export type CardState = 'open' | 'partial' | 'done'
298
299const hasText = (r: Response | undefined) => !!r?.text?.trim()
300export const partKey = (cardId: string, partId: string) => `${cardId}/${partId}`
301
302/** Whether the owner has responded to a card: wholly, in part, or not yet. */
303export function cardState(card: Card, draft: Draft): CardState {
304 const r = draft.responses[card.id]
305 switch (card.type) {
306 case 'question':
307 return r?.choice && (r.choice !== 'other' || hasText(r)) ? 'done' : hasText(r) ? 'done' : 'open'
308 case 'decision':
309 return r?.choice === 'amend' ? (hasText(r) ? 'done' : 'partial') : r?.choice ? 'done' : hasText(r) ? 'partial' : 'open'
310 case 'finding':
311 return r?.choice ? 'done' : hasText(r) ? 'partial' : 'open'
312 case 'plan':
313 return r?.choice ? 'done' : (r?.rows && Object.keys(r.rows).length) || hasText(r) ? 'partial' : 'open'
314 case 'explainer':
315 case 'read': {
316 const ids = card.type === 'explainer' ? card.steps.map((s, i) => s.id ?? `step-${i + 1}`) : card.sections.map(s => s.id)
317 const done = ids.filter(id => draft.responses[partKey(card.id, id)]?.isDone).length
318 const touched = ids.some(id => hasText(draft.responses[partKey(card.id, id)]))
319 return done === ids.length ? 'done' : done || touched ? 'partial' : 'open'
320 }
321 }
322}
323
324export const answeredCount = (pack: Pack, draft: Draft) => pack.cards.filter(c => cardState(c, draft) === 'done').length
325
326export function summaryOf(pack: Pack, draft: Draft, status: PackStatus, submittedAt?: string): PackSummary {
327 return {
328 id: pack.id,
329 title: pack.title,
330 label: pack.label,
331 status,
332 createdAt: pack.createdAt,
333 submittedAt,
334 cards: pack.cards.length,
335 answered: answeredCount(pack, draft),
336 types: [...new Set(pack.cards.map(c => c.type))],
337 }
338}
339
340// ---------- the outcome ----------
341
342const VERB: Record<string, string> = {
343 approve: 'Approved',
344 reject: 'Rejected',
345 amend: 'Approved with changes',
346 accept: 'Accepted',
347 discuss: 'Discuss',
348 go: 'Go ahead',
349 cancel: 'Cancelled',
350 other: 'Other answer',
351}
352
353const quoteBlock = (text: string) => text.trim().split('\n').map(l => `> ${l}`).join('\n')
354
355/** A plan row goes ahead unless the owner unticked it. */
356export const rowIsOn = (r: Response | undefined, rowId: string) => r?.rows?.[rowId] ?? true
357
358/** The owner's response to one card, as Markdown bullet lines. */
359export function cardOutcome(card: Card, draft: Draft): string[] {
360 const r = draft.responses[card.id]
361 const lines: string[] = []
362 const note = (label: string, t?: string) => t?.trim() && lines.push(`- **${label}:** ${t.trim().replace(/\n+/g, ' ')}`)
363 switch (card.type) {
364 case 'question': {
365 const picked = card.options?.find(o => o.id === r?.choice)
366 if (picked) lines.push(`- **Answer:** ${picked.label}${picked.id === card.recommended ? ' (the recommended option)' : ''}`)
367 else if (r?.choice === 'other' || hasText(r)) lines.push(`- **Answer:** ${r?.text?.trim() ?? ''}`)
368 else lines.push('- **Answer:** _none given_')
369 if (picked) note('Comment', r?.text)
370 break
371 }
372 case 'decision':
373 case 'finding':
374 lines.push(`- **${card.type === 'decision' ? 'Decision' : 'Response'}:** ${r?.choice ? VERB[r.choice] ?? r.choice : '_undecided_'}`)
375 note(card.type === 'decision' ? (r?.choice === 'amend' ? 'Changes' : 'Comment') : 'Reply', r?.text)
376 break
377 case 'plan': {
378 const rows = card.rows.map(row => ({ row, isOn: rowIsOn(r, row.id) }))
379 const on = rows.filter(x => x.isOn)
380 const verdict = r?.choice === 'cancel' ? 'Cancelled: write nothing' : !r?.choice ? '_undecided_' : on.length === rows.length ? 'Go ahead with every row' : `Go ahead with ${on.length} of ${rows.length} rows`
381 lines.push(`- **Plan:** ${verdict}`)
382 if (r?.choice && r.choice !== 'cancel') for (const { row, isOn } of rows) lines.push(` - [${isOn ? 'x' : ' '}] ${row.action} \`${row.target}\`${row.summary ? ` — ${row.summary}` : ''}`)
383 note('Comment', r?.text)
384 break
385 }
386 case 'explainer': {
387 const steps = card.steps.map((s, i) => ({ s, r: draft.responses[partKey(card.id, s.id ?? `step-${i + 1}`)] }))
388 lines.push(`- **Understood:** ${steps.filter(x => x.r?.isDone).length} of ${steps.length} steps`)
389 for (const { s, r: sr } of steps) if (hasText(sr)) lines.push(` - ${s.title}: ${sr!.text!.trim().replace(/\n+/g, ' ')}`)
390 break
391 }
392 case 'read': {
393 const parts = card.sections.map(s => ({ s, r: draft.responses[partKey(card.id, s.id)] }))
394 lines.push(`- **Reviewed:** ${parts.filter(x => x.r?.isDone).length} of ${parts.length} sections`)
395 for (const { s, r: sr } of parts) if (hasText(sr)) lines.push(` - ${s.title}: ${sr!.text!.trim().replace(/\n+/g, ' ')}`)
396 const notTicked = parts.filter(x => !x.r?.isDone).map(x => x.s.title)
397 if (notTicked.length && notTicked.length < parts.length) lines.push(` - Not ticked: ${notTicked.join(', ')}`)
398 break
399 }
400 }
401 return lines
402}
403
404/** One card as Markdown for the clipboard: what it says, its evidence and the owner's response so far. */
405export function cardText(pack: Pack, card: Card, draft: Draft): string {
406 const out = [`## ${card.title}`, '', `_${TYPE_LABEL[card.type]} ${card.id} · ${pack.title}_`, '']
407 const add = (...parts: (string | undefined | false)[]) => parts.forEach(x => x && out.push(x, ''))
408 switch (card.type) {
409 case 'question':
410 add(card.prompt, card.context, ...(card.options ?? []).map(o => `- ${o.label}${o.description ? ` (${o.description})` : ''}${o.id === card.recommended ? ' (recommended)' : ''}`))
411 break
412 case 'decision':
413 add(card.proposal, card.rationale && `**Why:** ${card.rationale}`, ...(card.alternatives ?? []).map(a => `- Not chosen: ${a.label}${a.reason ? ` (${a.reason})` : ''}`), card.consequences && `**What follows:** ${card.consequences}`)
414 break
415 case 'finding':
416 add(card.quote && quoteBlock(card.quote), card.body, card.suggestion && `**Suggested change:** ${card.suggestion}`, card.evidence && `**The record says**${card.evidence.ref ? ` (${card.evidence.ref})` : ''}: ${card.evidence.quote}`)
417 break
418 case 'plan':
419 add(card.intro, card.rows.map(r => `- ${r.action} \`${r.target}\`${r.summary ? `: ${r.summary}` : ''}`).join('\n'))
420 break
421 case 'explainer':
422 add(card.intro, ...card.steps.map((st, i) => `**${i + 1}. ${st.title}**\n\n${st.caption}`))
423 break
424 case 'read':
425 add(card.intro, ...card.sections.map(sec => `### ${sec.title}\n\n${sec.body}`))
426 break
427 }
428 const cited = [...('claims' in card ? card.claims ?? [] : []), ...(card.type === 'explainer' ? card.steps.flatMap(st => [...(st.claims ?? []), ...sceneClaims(st.scene)]) : [])]
429 const claims = [...new Set(cited)].map(id => pack.claims?.find(c => c.id === id)).filter((c): c is Claim => !!c)
430 if (claims.length) add('**Evidence**', claims.map(c => `- ${c.id}${c.grade ? ` (${c.grade})` : ''}: ${c.text}${c.url ? ` ${c.url}` : c.source ? ` (${c.source})` : ''}`).join('\n'))
431 const response = cardOutcome(card, draft)
432 if (response.length) add('**Response**', response.join('\n'))
433 return out.join('\n').trim()
434}
435
436/** The outcome as Markdown: what the owner decided, for Claude to apply in the record's real home. */
437export function outcomeMarkdown(pack: Pack, draft: Draft, submittedAt: string): string {
438 const out = [
439 `# Review outcome: ${pack.title}`,
440 '',
441 `- Review: \`${pack.id}\`${pack.label ? ` (${pack.label})` : ''}`,
442 `- Published: ${pack.createdAt}${pack.origin ? ` by ${pack.origin}` : ''}`,
443 `- Submitted: ${submittedAt}`,
444 `- Responded to: ${answeredCount(pack, draft)} of ${pack.cards.length} cards`,
445 ]
446 if (pack.sources?.length)
447 out.push(`- Mirrors: ${pack.sources.map(s => [s.id, s.title, s.path ? `\`${s.path}\`` : '', s.updated ? `(updated ${s.updated})` : ''].filter(Boolean).join(' ')).join('; ')}`)
448 out.push('', 'The source notes and files stay the record. Apply each response there, following the project\'s own rules.', '')
449 for (const card of pack.cards) {
450 out.push(`## ${card.id}: ${card.title} (${TYPE_LABEL[card.type].toLowerCase()})`, '')
451 if (card.type === 'finding' && card.quote) out.push(quoteBlock(card.quote), '')
452 out.push(...cardOutcome(card, draft), '')
453 }
454 const asks = draft.asks.filter(a => a.answer || a.via === 'session')
455 if (asks.length) {
456 out.push('## Questions asked during the review', '')
457 for (const a of asks) {
458 out.push(`- **Q${a.about ? ` (${a.about})` : ''}:** ${a.question.replace(/\n+/g, ' ')}`)
459 if (a.via === 'session' && !a.answer) out.push(' - Asked in the session; no answer was put on the card.')
460 else out.push(` - A${a.via === 'session' ? ' (Claude in the session)' : ''}: ${(a.answer ?? '').replace(/\n+/g, ' ')}`)
461 for (const s of a.sources ?? []) out.push(` - Source: ${s.url ? `[${s.title}](${s.url})` : s.title}`)
462 }
463 out.push('')
464 }
465 return out.join('\n')
466}
467
468export function outcomeJson(pack: Pack, draft: Draft, submittedAt: string) {
469 return {
470 schema: 1,
471 review: pack.id,
472 title: pack.title,
473 submittedAt,
474 cards: pack.cards.map(c => ({ id: c.id, type: c.type, title: c.title, state: cardState(c, draft) })),
475 responses: draft.responses,
476 asks: draft.asks,
477 }
478}
479
480/** The prompt that hands a submitted review back to the session. */
481export function handOffPrompt(pack: Pack, outcomePath: string): string {
482 return [
483 `I submitted the review "${pack.title}" (\`${pack.id}\`) in the review desk.`,
484 `The outcome is in \`${outcomePath}\`. Read it, then apply each response in its real home (the file or note it came from), following this project's own rules.`,
485 'Propose the changes and confirm with me before writing anything. Drop rejected and cancelled items; treat "Discuss" items, amendments and comments as questions to settle first.',
486 ].join('\n')
487}
488
489// ---------- the tool Claude publishes with ----------
490
491const tone = { type: 'string', enum: TONES as unknown as string[] }
492const sceneItem = { type: 'object', properties: { title: { type: 'string' }, text: { type: 'string' }, tone, claim: { type: 'string' } }, required: ['title'] }
493
494export const ANSWER_DESCRIPTION = [
495 'Put your answer to a question the owner asked from a review desk card onto that card, where they asked it.',
496 'Use it after a prompt that says "A question from the review desk": research first with whatever you need, then call this with the review and ask ids from that prompt.',
497 'Write the answer in Markdown short enough for a side panel; list what you drew on in "sources" with URLs. Never invent a source.',
498].join('\n')
499
500export const ANSWER_SCHEMA = {
501 type: 'object',
502 properties: {
503 review: { type: 'string', description: 'The review folder id from the question prompt.' },
504 ask: { type: 'string', description: 'The ask id from the question prompt.' },
505 answer: { type: 'string', description: 'The answer, in Markdown.' },
506 sources: {
507 type: 'array',
508 description: 'What the answer drew on.',
509 items: { type: 'object', properties: { title: { type: 'string' }, url: { type: 'string' } }, required: ['title'] },
510 },
511 },
512 required: ['review', 'ask', 'answer'],
513}
514
515export const PUBLISH_DESCRIPTION = [
516 'Put work in front of the owner for review in the review desk, a side panel in Claude Code: open questions to answer, proposed decisions to approve, findings to accept or reject, a write plan to approve row by row, an animated explainer to walk through, or a long text to read section by section.',
517 'Use it instead of building an HTML review or explainer page and instead of asking several questions in chat. The pack is saved under Reviews/pending/<id>/ in the project; the owner answers in the panel, can ask questions there, and submits. You then receive a prompt pointing at Reviews/reviewed/<id>/outcome.md.',
518 'When the work comes from files or notes, they stay the record: mirror them, cite their ids, and never put a fact here that they do not hold. Claims are optional; list every claim a card or scene cites in "claims", graded A (primary) to D (inferred) when the project grades its evidence.',
519 'Card types: question (prompt, options[], recommended), decision (proposal, rationale, alternatives[]), finding (body, severity, kind drift|review, quote, suggestion, evidence), plan (rows[] of action + target + summary), explainer (steps[] of title, caption, scene), read (sections[] of title + body, flags[]).',
520 'Scenes animate in the panel: cards, flow, list, timeline (items[] of title, text, tone, claim); funnel (items[] of label, value); bars (items[] of label, value, display; max; mark); tiles (items[] of value, label); compare (columns[], rows[] of label + cells[]); matrix (x, y, quadrants[4], dots[] of label, x 0–1, y 0–1). Tones: subject, context, risk, question, decision, good.',
521 'Publishing the same id again replaces the pack and keeps the owner\'s answers for cards whose ids stay the same.',
522].join('\n')
523
524export const PUBLISH_SCHEMA = {
525 type: 'object',
526 properties: {
527 id: { type: 'string', description: 'Folder-safe id, e.g. "INIT-002-open-questions". Reuse it to update a pending review. Default: date + title.' },
528 title: { type: 'string', description: 'What is being reviewed, in plain words.' },
529 label: { type: 'string', description: 'A short label for the list: "Open questions", "Write plan", "Deck review".' },
530 summary: { type: 'string', description: 'Markdown: what this review is for and what the owner is asked to do.' },
531 origin: { type: 'string', description: 'The skill or task publishing it.' },
532 sources: {
533 type: 'array',
534 description: 'The notes or files this pack mirrors.',
535 items: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, path: { type: 'string' }, updated: { type: 'string' } } },
536 },
537 claims: {
538 type: 'array',
539 description: 'Every claim a card or scene cites, graded.',
540 items: {
541 type: 'object',
542 properties: { id: { type: 'string' }, text: { type: 'string' }, grade: { type: 'string', enum: ['A', 'B', 'C', 'D'] }, source: { type: 'string' }, url: { type: 'string' }, checked: { type: 'string' } },
543 required: ['id', 'text'],
544 },
545 },
546 cards: {
547 type: 'array',
548 description: 'The cards, in the order the owner should meet them.',
549 items: {
550 type: 'object',
551 properties: {
552 type: { type: 'string', enum: CARD_TYPES as unknown as string[] },
553 id: { type: 'string', description: 'Stable id, e.g. the id of the note or item it comes from, such as "Q-13" or "ADR-041".' },
554 title: { type: 'string' },
555 prompt: { type: 'string' },
556 context: { type: 'string' },
557 options: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, label: { type: 'string' }, description: { type: 'string' } }, required: ['label'] } },
558 recommended: { type: 'string' },
559 allowOther: { type: 'boolean' },
560 proposal: { type: 'string' },
561 rationale: { type: 'string' },
562 alternatives: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, reason: { type: 'string' } }, required: ['label'] } },
563 consequences: { type: 'string' },
564 body: { type: 'string' },
565 severity: { type: 'string', enum: ['high', 'medium', 'low'] },
566 kind: { type: 'string', enum: ['drift', 'review'] },
567 where: { type: 'string' },
568 quote: { type: 'string' },
569 suggestion: { type: 'string' },
570 evidence: { type: 'object', properties: { ref: { type: 'string' }, quote: { type: 'string' } } },
571 intro: { type: 'string' },
572 rows: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, action: { type: 'string' }, target: { type: 'string' }, summary: { type: 'string' } }, required: ['target'] } },
573 steps: {
574 type: 'array',
575 items: {
576 type: 'object',
577 properties: {
578 title: { type: 'string' },
579 caption: { type: 'string' },
580 claims: { type: 'array', items: { type: 'string' } },
581 scene: {
582 type: 'object',
583 properties: {
584 kind: { type: 'string', enum: SCENES },
585 items: { type: 'array', items: { ...sceneItem, properties: { ...sceneItem.properties, label: { type: 'string' }, value: {}, display: { type: 'string' } }, required: [] } },
586 max: { type: 'number' },
587 mark: { type: 'object', properties: { value: { type: 'number' }, label: { type: 'string' } } },
588 columns: { type: 'array', items: { type: 'string' } },
589 rows: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, cells: { type: 'array', items: { type: 'string' } }, tone } } },
590 x: { type: 'string' },
591 y: { type: 'string' },
592 quadrants: { type: 'array', items: { type: 'string' } },
593 dots: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, tone, claim: { type: 'string' } } } },
594 },
595 required: ['kind'],
596 },
597 },
598 required: ['title', 'caption'],
599 },
600 },
601 sections: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' }, flags: { type: 'array', items: { type: 'string' } } }, required: ['title', 'body'] } },
602 claims: { type: 'array', items: { type: 'string' }, description: 'Claim ids this card rests on.' },
603 },
604 required: ['type', 'title'],
605 },
606 },
607 },
608 required: ['title', 'cards'],
609}
610hooks/view.tsx 756 lines1// The pane's drawing: the list of reviews and one review's cards, for the desktop and the terminal.
2// Layout, top to bottom: where you are (back, title, progress), which card (stepper and arrows),
3// the card itself in a frame (what it says, your answer, questions about it), then submitting.
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { Ask, Card, CardType, Claim, DeskView, OpenPack, PackSummary, Response } from '../types'
7import { TYPE_LABEL, cardOutcome, cardState, cardText, partKey, rowIsOn } from './pack'
8import type { CardState } from './pack'
9import { bubbleSvg, markdownSvg, thinkingSvg } from './mdsvg'
10import { deskHeaderSvg, ringSvg, sceneSvg, sceneText, stepperSvg } from './scenes'
11
12type UI = Elements['terminal'] | Elements['desktop'] | Elements['vscode'] | Elements['mobile']
13
14/** What the pane's controls do; every one saves what it changes. */
15export type Actions = {
16 tab: (tab: DeskView['tab']) => void
17 refresh: () => void
18 openPack: (status: DeskView['tab'], id: string) => void
19 closePack: () => void
20 goCard: (n: number) => void
21 goPart: (cardId: string, n: number) => void
22 respond: (key: string, patch: Partial<Response>) => void
23 toggleRow: (cardId: string, rowId: string) => void
24 type: (key: string, text: string) => void
25 commit: (key: string, text: string) => void
26 toggleSummary: () => void
27 showAllAsks: () => void
28 submit: () => void
29 confirm: (isOn: boolean) => void
30 resend: () => void
31 /** Puts text on the clipboard of the surface the press came from. */
32 copy: (text: string, surface: string) => void
33}
34
35export type Ctx = {
36 ui: UI
37 isDesktop: boolean
38 width: number
39 packs: PackSummary[]
40 view: DeskView
41 open: OpenPack | null
42 act: Actions
43 folder: string
44 /** Draw text blocks as interactive SVG, whose text the desktop lets you select and copy. */
45 selectable: boolean
46}
47
48const GLYPH: Record<CardState, string> = { done: '●', partial: '◐', open: '○' }
49/** One colour per card type, mid-tones that read on light and dark. */
50const TYPE_COLOR: Record<CardType, string> = {
51 question: '#9575CD',
52 decision: '#42A5F5',
53 finding: '#FFA726',
54 plan: '#EC407A',
55 explainer: '#26A69A',
56 read: '#8D8B85',
57}
58const SEVERITY_COLOR: Record<string, string> = { high: '#EF5350', medium: '#FFA726', low: '#8D8B85' }
59const when = (iso?: string) => (iso ? iso.slice(0, 16).replace('T', ' ') : '')
60const bar = (done: number, total: number, width = 10) => {
61 const n = total ? Math.round((done / total) * width) : 0
62 return '▰'.repeat(n) + '▱'.repeat(width - n)
63}
64
65/** How text blocks are drawn in the current drawing, by its element table (one per render). */
66const drawing = new WeakMap<object, { width: number; isSvg: boolean }>()
67const drawText = (ctx: Ctx) => drawing.set(ctx.ui, { width: Math.max(240, ctx.width - 40), isSvg: ctx.selectable && ctx.isDesktop && 'Svg' in ctx.ui })
68
69const LINK = /\[[^\]]+\]\([^)\s]+\)|https?:\/\/\S+/g
70
71/**
72 * Whether a block is mostly links (a source list, evidence): those stay the surface's own Markdown,
73 * so the links open; the Copy button copies them. Ordinary text with a link or two is drawn
74 * selectable, each link followed by its address in brackets.
75 */
76export function isLinkHeavy(text: string): boolean {
77 const links = text.match(LINK) ?? []
78 if (links.length >= 3) return true
79 const linked = links.reduce((n, l) => n + l.length, 0)
80 return links.length > 0 && linked / Math.max(1, text.length) > 0.4
81}
82
83/**
84 * A block of Markdown. On the desktop it is drawn as SVG text, which can be selected and copied,
85 * unless its links must stay clickable (`links: 'open'`, or a block that is mostly links).
86 */
87const md = (ui: UI, text: string | undefined, key?: string, dim?: boolean, links?: 'open'): RenderElement | null => {
88 if (!text?.trim()) return null
89 const how = drawing.get(ui)
90 if (how?.isSvg && 'Svg' in ui && links !== 'open' && !isLinkHeavy(text)) {
91 const d = markdownSvg(text, how.width, !!dim)
92 return <ui.Svg key={key} source={d.svg} alt={d.alt} width={how.width} height={d.height} isInteractive />
93 }
94 const { Markdown } = ui
95 return <Markdown key={key} text={text} dimColor={dim} />
96}
97
98/** A small capitalised heading inside a card: what this part of it is. */
99const label = (ui: UI, text: string, key: string) => {
100 const { Text } = ui
101 return (
102 <Text key={key} bold dimColor>
103 {text.toUpperCase()}
104 </Text>
105 )
106}
107
108/** A text field where the surface has one; otherwise nothing (the mobile app draws no fields yet). */
109function field(ctx: Ctx, key: string, caption: string, value: string | undefined, placeholder?: string, submitLabel = 'save'): RenderElement | null {
110 const ui = ctx.ui
111 if (!('Input' in ui)) return null
112 const { Input } = ui
113 return (
114 <Input
115 key={key}
116 label={caption || undefined}
117 value={value ?? ''}
118 placeholder={placeholder}
119 submitLabel={submitLabel}
120 onInput={v => ctx.act.type(key, v)}
121 onSubmit={v => ctx.act.commit(key, v)}
122 />
123 )
124}
125
126// ---------- the list ----------
127
128export function homeView(ctx: Ctx): RenderElement {
129 drawText(ctx)
130 const { ui, packs, view, act } = ctx
131 const { Box, Text, Button } = ui
132 const pending = packs.filter(p => p.status === 'pending')
133 const reviewed = packs.filter(p => p.status === 'reviewed')
134 const shown = view.tab === 'pending' ? pending : reviewed
135 const answered = pending.reduce((n, p) => n + p.answered, 0)
136 const cards = pending.reduce((n, p) => n + p.cards, 0)
137
138 let header: RenderElement
139 if (ctx.isDesktop && 'Svg' in ui) {
140 const { Svg } = ui
141 const d = deskHeaderSvg(ctx.width, pending.length, reviewed.length, answered, cards)
142 header = <Svg key="desk-header" source={d.svg} alt={d.alt} width={ctx.width} height={d.height} />
143 } else {
144 header = (
145 <Box flexDirection="column">
146 <Text bold>Review desk</Text>
147 <Text dimColor>
148 {pending.length ? `${pending.length} waiting for you` : 'Nothing waiting'} · {reviewed.length} reviewed
149 </Text>
150 </Box>
151 )
152 }
153
154 const row = (p: PackSummary) => {
155 const isDone = p.status === 'reviewed'
156 const progress = isDone ? `✓ submitted ${when(p.submittedAt)}` : `${bar(p.answered, p.cards)} ${p.answered} of ${p.cards} answered`
157 const kinds = p.types.map(t => TYPE_LABEL[t].toLowerCase()).join(', ')
158 return (
159 <Box key={`row-${p.status}-${p.id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
160 <Button key={`open-${p.status}-${p.id}`} plain onPress={() => act.openPack(p.status, p.id)}>
161 {p.title}
162 <Text dimColor> ›</Text>
163 </Button>
164 <Text color={isDone ? '#66BB6A' : undefined} dimColor={!isDone} wrap="truncate-end">
165 {progress}
166 </Text>
167 <Text dimColor wrap="truncate-end">
168 {[p.label, kinds, isDone ? '' : `published ${when(p.createdAt)}`].filter(Boolean).join(' · ')}
169 </Text>
170 </Box>
171 )
172 }
173
174 return (
175 <Box flexDirection="column" gap={1}>
176 {header}
177 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
178 <Button key="tab-pending" variant={view.tab === 'pending' ? 'primary' : 'secondary'} label={`Waiting · ${pending.length}`} onPress={() => act.tab('pending')} />
179 <Button key="tab-reviewed" variant={view.tab === 'reviewed' ? 'primary' : 'secondary'} label={`Reviewed · ${reviewed.length}`} onPress={() => act.tab('reviewed')} />
180 <Button key="refresh" plain dimColor label="↻ Refresh" onPress={act.refresh} />
181 </Box>
182 {shown.length === 0 && (
183 <Text dimColor>
184 {view.tab === 'pending'
185 ? 'Nothing is waiting for review. Ask Claude to put something up, for example: "put the open questions up for review" or "send this write plan to the review desk".'
186 : 'No reviews submitted yet.'}
187 </Text>
188 )}
189 {shown.map(row)}
190 <Text dimColor wrap="truncate-end">
191 Saved in {ctx.folder}/{view.tab === 'pending' ? 'pending' : 'reviewed'}/
192 </Text>
193 </Box>
194 )
195}
196
197// ---------- one review: the card's parts ----------
198
199function evidence(ui: UI, ids: string[] | undefined, claims: Claim[] | undefined, key: string): RenderElement | null {
200 const list = [...new Set(ids ?? [])].map(id => claims?.find(c => c.id === id)).filter((c): c is Claim => !!c)
201 if (!list.length) return null
202 const { Box } = ui
203 const text = list
204 .map(c => {
205 const grade = c.grade ? `**${c.id} · ${c.grade}**` : `**${c.id} · ungraded**`
206 const src = c.url ? ` [${c.source ?? 'source'}](${c.url})` : c.source ? ` _${c.source}_` : ''
207 return `- ${grade} ${c.text}${src}${c.checked ? `, checked ${c.checked}` : ''}`
208 })
209 .join('\n')
210 return (
211 <Box key={key} flexDirection="column">
212 {label(ui, 'Evidence', `${key}-h`)}
213 {md(ui, text, `${key}-b`, true, 'open')}
214 </Box>
215 )
216}
217
218/** A list of choices, one per line; the one picked is filled. */
219function choices(ctx: Ctx, cardId: string, picked: string | undefined, list: { id: string; label: string; detail?: string; isTip?: boolean }[]): RenderElement {
220 const { Box, Button, Text } = ctx.ui
221 return (
222 <Box flexDirection="column">
223 {list.map(o => (
224 <Button key={`choice-${cardId}-${o.id}`} plain onPress={() => ctx.act.respond(cardId, { choice: picked === o.id ? undefined : o.id })}>
225 {picked === o.id ? '◉ ' : '○ '}
226 {o.label}
227 {o.isTip ? <Text color="#66BB6A"> ★ recommended</Text> : ''}
228 {o.detail ? <Text dimColor> · {o.detail}</Text> : ''}
229 </Button>
230 ))}
231 </Box>
232 )
233}
234
235/** A row of verb buttons (Approve, Reject…); the one picked is the primary button. */
236function verbs(ctx: Ctx, cardId: string, picked: string | undefined, list: { id: string; label: string }[]): RenderElement {
237 const { Box, Button } = ctx.ui
238 return (
239 <Box flexDirection="row" gap={1} flexWrap="wrap">
240 {list.map(v => (
241 <Button
242 key={`verb-${cardId}-${v.id}`}
243 variant={picked === v.id ? 'primary' : 'secondary'}
244 label={`${picked === v.id ? '✓ ' : ''}${v.label}`}
245 onPress={() => ctx.act.respond(cardId, { choice: picked === v.id ? undefined : v.id })}
246 />
247 ))}
248 </Box>
249 )
250}
251
252/** Steps or sections inside a card: back, the tick, forward. */
253function partNav(ctx: Ctx, card: Card, at: number, count: number, noun: string, isDone: boolean, doneLabel: string): RenderElement {
254 const { Box, Button } = ctx.ui
255 const key = partKey(card.id, card.type === 'explainer' ? card.steps[at]?.id ?? `step-${at + 1}` : card.type === 'read' ? card.sections[at]?.id ?? '' : '')
256 const isLast = at >= count - 1
257 return (
258 <Box flexDirection="row" gap={1} flexWrap="wrap">
259 <Button key={`part-prev-${card.id}`} plain dimColor={at === 0} label={`‹ ${noun}`} onPress={() => ctx.act.goPart(card.id, Math.max(0, at - 1))} />
260 {ctx.open?.status === 'pending' && (
261 <Button
262 key={`part-done-${card.id}`}
263 variant={isDone ? 'secondary' : 'primary'}
264 label={isDone ? `✓ ${doneLabel}` : `${doneLabel}${isLast ? '' : ', next ›'}`}
265 onPress={() => {
266 ctx.act.respond(key, { isDone: !isDone })
267 if (!isDone && !isLast) ctx.act.goPart(card.id, at + 1)
268 }}
269 />
270 )}
271 <Button key={`part-next-${card.id}`} plain dimColor={isLast} label={`${noun} ›`} onPress={() => ctx.act.goPart(card.id, Math.min(count - 1, at + 1))} />
272 </Box>
273 )
274}
275
276/** What the card says, and the controls to answer it: two blocks, each with its own heading. */
277function cardParts(ctx: Ctx, card: Card): { content: RenderElement; answer: RenderElement | null } {
278 const { ui, open, view } = ctx
279 const { Box, Text, Button } = ui
280 const pack = open!.pack
281 const draft = open!.draft
282 const r = draft.responses[card.id]
283 const isPending = open!.status === 'pending'
284 const col = (key: string, ...children: (RenderElement | null | false | undefined)[]) => (
285 <Box key={key} flexDirection="column" gap={1}>
286 {children.filter(Boolean)}
287 </Box>
288 )
289
290 switch (card.type) {
291 case 'question':
292 return {
293 content: col(`c-${card.id}`, md(ui, card.prompt, `q-${card.id}`), md(ui, card.context, `qc-${card.id}`, true), evidence(ui, card.claims, pack.claims, `qe-${card.id}`)),
294 answer: isPending
295 ? col(
296 `a-${card.id}`,
297 card.options &&
298 choices(ctx, card.id, r?.choice, [
299 ...card.options.map(o => ({ id: o.id, label: o.label, detail: o.description, isTip: o.id === card.recommended })),
300 ...(card.allowOther !== false ? [{ id: 'other', label: 'Something else' }] : []),
301 ]),
302 field(
303 ctx,
304 `t:${card.id}`,
305 card.options && r?.choice !== 'other' ? 'Comment' : 'Answer',
306 r?.text,
307 card.options && r?.choice !== 'other' ? 'optional' : 'type your answer, Enter to save',
308 ),
309 )
310 : null,
311 }
312 case 'decision':
313 return {
314 content: col(
315 `c-${card.id}`,
316 md(ui, card.proposal, `d-${card.id}`),
317 md(ui, card.rationale ? `**Why:** ${card.rationale}` : '', `dr-${card.id}`),
318 md(ui, card.alternatives?.length ? `**Considered, not chosen**\n\n${card.alternatives.map(a => `- ${a.label}${a.reason ? ` — ${a.reason}` : ''}`).join('\n')}` : '', `da-${card.id}`),
319 md(ui, card.consequences ? `**What follows:** ${card.consequences}` : '', `dc-${card.id}`, true),
320 evidence(ui, card.claims, pack.claims, `de-${card.id}`),
321 ),
322 answer: isPending
323 ? col(
324 `a-${card.id}`,
325 verbs(ctx, card.id, r?.choice, [
326 { id: 'approve', label: 'Approve' },
327 { id: 'amend', label: 'Approve with changes' },
328 { id: 'reject', label: 'Reject' },
329 ]),
330 field(ctx, `t:${card.id}`, r?.choice === 'amend' ? 'Changes' : 'Comment', r?.text, r?.choice === 'amend' ? 'what to change' : 'optional'),
331 )
332 : null,
333 }
334 case 'finding':
335 return {
336 content: col(
337 `c-${card.id}`,
338 <Text key={`fm-${card.id}`} wrap="truncate-end">
339 {card.severity ? (
340 <Text color={SEVERITY_COLOR[card.severity]} bold>
341 {card.severity.toUpperCase()}
342 </Text>
343 ) : (
344 ''
345 )}
346 <Text dimColor>
347 {[card.severity ? '' : undefined, card.kind === 'drift' ? 'differs from the record' : card.kind === 'review' ? 'reviewer judgement' : '', card.where]
348 .filter(x => x !== undefined && x !== '')
349 .map(x => ` · ${x}`)
350 .join('')}
351 </Text>
352 </Text>,
353 md(ui, card.quote ? `> ${card.quote.replace(/\n/g, '\n> ')}` : '', `fq-${card.id}`),
354 md(ui, card.body, `fb-${card.id}`),
355 md(ui, card.suggestion ? `**Suggested change:** ${card.suggestion}` : '', `fs-${card.id}`),
356 md(ui, card.evidence ? `**The record says**${card.evidence.ref ? ` (${card.evidence.ref})` : ''}\n\n> ${card.evidence.quote}` : '', `fe-${card.id}`, true),
357 evidence(ui, card.claims, pack.claims, `fc-${card.id}`),
358 ),
359 answer: isPending
360 ? col(
361 `a-${card.id}`,
362 verbs(ctx, card.id, r?.choice, [
363 { id: 'accept', label: 'Accept' },
364 { id: 'reject', label: 'Reject' },
365 { id: 'discuss', label: 'Discuss' },
366 ]),
367 field(ctx, `t:${card.id}`, 'Reply', r?.text, 'optional'),
368 )
369 : null,
370 }
371 case 'plan': {
372 const on = card.rows.filter(row => rowIsOn(r, row.id)).length
373 const rows = (
374 <Box key={`pr-${card.id}`} flexDirection="column">
375 {card.rows.map(row => {
376 const isOn = rowIsOn(r, row.id) && (isPending || r?.choice !== 'cancel')
377 const inner = [
378 isOn ? '☑ ' : '☐ ',
379 <Text bold>{row.action}</Text>,
380 ` ${row.target}`,
381 row.summary ? <Text dimColor> · {row.summary}</Text> : '',
382 ]
383 return isPending ? (
384 <Button key={`row-${card.id}-${row.id}`} plain onPress={() => ctx.act.toggleRow(card.id, row.id)}>
385 {inner}
386 </Button>
387 ) : (
388 <Text key={`row-${card.id}-${row.id}`}>{inner}</Text>
389 )
390 })}
391 </Box>
392 )
393 return {
394 content: col(`c-${card.id}`, md(ui, card.intro, `pi-${card.id}`), isPending ? null : rows),
395 answer: isPending
396 ? col(
397 `a-${card.id}`,
398 <Text key={`ph-${card.id}`} dimColor>
399 Untick any row you do not want, then choose.
400 </Text>,
401 rows,
402 verbs(ctx, card.id, r?.choice, [
403 { id: 'go', label: on === card.rows.length ? 'Go ahead with all' : `Go ahead with ${on} of ${card.rows.length}` },
404 { id: 'cancel', label: 'Cancel: write nothing' },
405 ]),
406 field(ctx, `t:${card.id}`, 'Comment', r?.text, 'edits to the plan, optional'),
407 )
408 : null,
409 }
410 }
411 case 'explainer': {
412 const n = card.steps.length
413 const at = Math.min(view.part[card.id] ?? 0, n - 1)
414 const step = card.steps[at]
415 if (!step) return { content: <Text dimColor>This explainer has no steps.</Text>, answer: null }
416 const key = partKey(card.id, step.id ?? `step-${at + 1}`)
417 const sr = draft.responses[key]
418 let scene: RenderElement | null = null
419 if (step.scene) {
420 if (ctx.isDesktop && 'Svg' in ui) {
421 const { Svg } = ui
422 const w = Math.max(240, ctx.width - 40)
423 const d = sceneSvg(w, step.scene, pack.claims)
424 scene = <Svg key={`scene-${card.id}-${at}`} source={d.svg} alt={d.alt} width={w} height={d.height} isInteractive />
425 } else {
426 scene = (
427 <Box key={`scene-${card.id}-${at}`} flexDirection="column">
428 {sceneText(step.scene).map((l, i) => (
429 <Text key={`scene-${card.id}-${at}-${i}`}>{l}</Text>
430 ))}
431 </Box>
432 )
433 }
434 }
435 const sceneClaims =
436 step.scene && step.scene.kind === 'matrix'
437 ? step.scene.dots.map(d => d.claim)
438 : step.scene && 'items' in step.scene
439 ? (step.scene.items as { claim?: string }[]).map(i => i.claim)
440 : []
441 return {
442 content: col(
443 `c-${card.id}`,
444 at === 0 && md(ui, card.intro, `ei-${card.id}`, true),
445 <Text key={`eh-${card.id}`}>
446 <Text dimColor>
447 Step {at + 1} of {n} ·{' '}
448 </Text>
449 <Text bold>{step.title}</Text>
450 </Text>,
451 scene,
452 md(ui, step.caption, `ec-${card.id}-${at}`),
453 evidence(ui, [...(step.claims ?? []), ...sceneClaims.filter((c): c is string => !!c)], pack.claims, `el-${card.id}-${at}`),
454 partNav(ctx, card, at, n, 'Step', !!sr?.isDone, 'Got it'),
455 ),
456 answer: isPending ? field(ctx, `t:${key}`, 'Note on this step', sr?.text, 'optional') : null,
457 }
458 }
459 case 'read': {
460 const n = card.sections.length
461 const at = Math.min(view.part[card.id] ?? 0, n - 1)
462 const s = card.sections[at]
463 if (!s) return { content: <Text dimColor>This read-through has no sections.</Text>, answer: null }
464 const key = partKey(card.id, s.id)
465 const sr = draft.responses[key]
466 const jump =
467 n > 1 && 'Select' in ui ? (
468 <ui.Select
469 key={`jump-${card.id}`}
470 label="Section"
471 value={String(at)}
472 options={card.sections.map((x, i) => ({ value: String(i), label: `${i + 1}. ${x.title}${draft.responses[partKey(card.id, x.id)]?.isDone ? ' ✓' : ''}` }))}
473 onSelect={v => ctx.act.goPart(card.id, Number(v))}
474 />
475 ) : null
476 return {
477 content: col(
478 `c-${card.id}`,
479 at === 0 && md(ui, card.intro, `ri-${card.id}`, true),
480 jump,
481 <Text key={`rh-${card.id}`}>
482 <Text dimColor>
483 Section {at + 1} of {n} ·{' '}
484 </Text>
485 <Text bold>{s.title}</Text>
486 </Text>,
487 md(ui, (s.flags ?? []).map(f => `> **Your call:** ${f}`).join('\n\n'), `rf-${card.id}-${at}`),
488 md(ui, s.body, `rb-${card.id}-${at}`),
489 partNav(ctx, card, at, n, 'Section', !!sr?.isDone, 'Reviewed'),
490 ),
491 answer: isPending ? field(ctx, `t:${key}`, 'Note on this section', sr?.text, 'optional') : null,
492 }
493 }
494 }
495}
496
497/** Questions belong to the card they were asked on (older ones are matched by the card's title). */
498export const asksOf = (asks: Ask[], card: Card) => asks.filter(a => (a.card ? a.card === card.id : a.about === card.title || a.about?.startsWith(`${card.title}: `)))
499
500const clock = (iso: string) => iso.slice(11, 16)
501
502/** One question as a bubble on the right, as Claude Desktop draws what you sent. */
503function questionBubble(ctx: Ctx, a: Ask): RenderElement {
504 const { ui } = ctx
505 const { Box, Text } = ui
506 if (ctx.isDesktop && 'Svg' in ui) {
507 const d = bubbleSvg(a.question, Math.max(200, Math.round((ctx.width - 40) * 0.8)))
508 return (
509 <Box key={`q-${a.id}`} flexDirection="row" justifyContent="flex-end">
510 <ui.Svg source={d.svg} alt={d.alt} width={d.width} height={d.height} isInteractive />
511 </Box>
512 )
513 }
514 return (
515 <Box key={`q-${a.id}`} flexDirection="row" justifyContent="flex-end">
516 <Text inverse> {a.question} </Text>
517 </Box>
518 )
519}
520
521/** Claude's reply as plain text on the left, its sources under it, a quiet Copy beside them. */
522function reply(ctx: Ctx, a: Ask): RenderElement {
523 const { ui, act } = ctx
524 const { Box, Text, Button } = ui
525 if (!a.answer) {
526 const dots = ctx.isDesktop && 'Svg' in ui ? thinkingSvg() : undefined
527 return (
528 <Box key={`r-${a.id}`} flexDirection="row" gap={1} alignItems="center">
529 {dots && 'Svg' in ui ? <ui.Svg source={dots.svg} alt={dots.alt} width={dots.width} height={dots.height} isInteractive /> : <Text color="#D97757">●●●</Text>}
530 <Text dimColor italic>
531 Claude is looking into it…
532 </Text>
533 </Box>
534 )
535 }
536 const sources = (a.sources ?? []).map(x => `- ${x.url ? `[${x.title}](${x.url})` : x.title}`).join('\n')
537 return (
538 <Box key={`r-${a.id}`} flexDirection="column" gap={1}>
539 {md(ui, a.answer, `ans-${a.id}`, !!a.isError)}
540 {sources && md(ui, `**Sources**\n\n${sources}`, `src-${a.id}`, true, 'open')}
541 <Box flexDirection="row" gap={1}>
542 <Button key={`copy-ask-${a.id}`} plain dimColor label="⧉ Copy" onPress={p => act.copy(askText(a), p.surface)} />
543 <Text dimColor>{clock(a.answeredAt ?? a.at)}</Text>
544 </Box>
545 </Box>
546 )
547}
548
549/** One question and its answer as plain Markdown, for the clipboard. */
550export function askText(a: Ask): string {
551 const lines = [`**Q:** ${a.question}`, '', `**A:** ${a.answer ?? ''}`]
552 if (a.sources?.length) lines.push('', 'Sources:', ...a.sources.map(x => `- ${x.url ? `${x.title}: ${x.url}` : x.title}`))
553 return lines.join('\n')
554}
555
556/**
557 * A conversation with Claude about the card, under it: your questions on the right, Claude's answers
558 * on the left, and a composer at the bottom. Every question goes to Claude in the session, which can
559 * search the web and read the project, and the answer comes back here.
560 */
561function chatBlock(ctx: Ctx, card: Card): RenderElement {
562 const { ui, open, view, act } = ctx
563 const { Box, Text, Button } = ui
564 const asks = asksOf(open!.draft.asks, card)
565 const shown = view.showAllAsks ? asks : asks.slice(-4)
566 return (
567 <Box key={`chat-${card.id}`} flexDirection="column" gap={1} marginTop={1}>
568 <Text>
569 <Text color="#D97757" bold>
570 ✻{' '}
571 </Text>
572 <Text bold>Ask Claude about this card</Text>
573 </Text>
574 {asks.length === 0 && <Text dimColor>Claude can search the web and read your project. The answer appears here.</Text>}
575 {asks.length > shown.length && <Button key={`ask-all-${card.id}`} plain dimColor label={`Show ${asks.length - shown.length} earlier`} onPress={act.showAllAsks} />}
576 {shown.flatMap(a => [questionBubble(ctx, a), reply(ctx, a)])}
577 {'Input' in ui && (
578 <Box key={`composer-${card.id}`} flexDirection="column" alignItems="stretch" width="100%" borderStyle="round" borderDimColor paddingX={1}>
579 {/* The field takes no width of its own: it is stretched across the composer. */}
580 <Box flexDirection="column" alignItems="stretch" width="100%">
581 {/* Keyed by the count asked: each question asked draws a fresh, empty field. */}
582 {/* The field's own button (and Enter) sends: one way to send, as in Claude Desktop. */}
583 {field(ctx, `ask:${card.id}#${asks.length}`, '', '', 'Ask Claude about this card…', '↑')}
584 </Box>
585 </Box>
586 )}
587 </Box>
588 )
589}
590
591// ---------- one review ----------
592
593export function packView(ctx: Ctx): RenderElement {
594 drawText(ctx)
595 const { ui, open, view, act } = ctx
596 const { Box, Text, Button } = ui
597 if (!open) {
598 return (
599 <Box flexDirection="column" gap={1}>
600 <Button key="back" plain dimColor label="‹ All reviews" onPress={act.closePack} />
601 <Text dimColor>This review could not be read. It may have been moved or edited by hand.</Text>
602 </Box>
603 )
604 }
605 const { pack, draft } = open
606 const n = pack.cards.length
607 const at = Math.min(view.card, n - 1)
608 const card = pack.cards[at]
609 if (!card) return <Text dimColor>This review has no cards.</Text>
610 const states = pack.cards.map(c => cardState(c, draft))
611 const answered = states.filter(s => s === 'done').length
612 const isPending = open.status === 'pending'
613 const isDesktop = ctx.isDesktop && 'Svg' in ui
614
615 // 1. Where you are.
616 const meta = [pack.label, isPending ? `published ${when(pack.createdAt)}` : `submitted ${when(open.submittedAt)}`, pack.origin].filter(Boolean).join(' · ')
617 let ring: RenderElement | null = null
618 if (isDesktop && 'Svg' in ui) {
619 const d = ringSvg(answered, n)
620 ring = <ui.Svg key={`ring-${answered}`} source={d.svg} alt={d.alt} width={d.height} height={d.height} />
621 }
622 const top = (
623 <Box key="top" flexDirection="column">
624 <Button key="back" plain dimColor label="‹ All reviews" onPress={act.closePack} />
625 <Box flexDirection="row" gap={1} alignItems="center">
626 {ring}
627 <Box flexDirection="column" flexShrink={1}>
628 <Text bold>{pack.title}</Text>
629 <Text dimColor wrap="truncate-end">
630 {isDesktop ? meta : `${answered} of ${n} answered · ${meta}`}
631 </Text>
632 </Box>
633 </Box>
634 </Box>
635 )
636
637 const sources = pack.sources?.length
638 ? `\n\n**Mirrors:** ${pack.sources.map(s => [s.id, s.title, s.path ? `\`${s.path}\`` : '', s.updated ? `(updated ${s.updated})` : ''].filter(Boolean).join(' ')).join('; ')}`
639 : ''
640 const about =
641 pack.summary || sources ? (
642 <Box key="about" flexDirection="column">
643 <Button key="about" plain dimColor={!view.isSummaryOpen} label={`${view.isSummaryOpen ? '▾' : '▸'} About this review`} onPress={act.toggleSummary} />
644 {view.isSummaryOpen && <Box paddingLeft={2}>{md(ui, `${pack.summary ?? ''}${sources}`, 'summary')}</Box>}
645 </Box>
646 ) : null
647
648 // 2. Which card.
649 let stepper: RenderElement
650 if (isDesktop && 'Svg' in ui) {
651 const d = stepperSvg(ctx.width, states, at)
652 stepper = <ui.Svg key={`stepper-${at}-${answered}`} source={d.svg} alt={d.alt} width={ctx.width} height={d.height} />
653 } else {
654 stepper = <Text wrap="truncate-end">{states.map((s, i) => (i === at ? `[${GLYPH[s]}]` : GLYPH[s])).join(' ')}</Text>
655 }
656 const jump =
657 n > 1 && 'Select' in ui ? (
658 <ui.Select
659 key="jump-card"
660 value={String(at)}
661 options={pack.cards.map((c, i) => ({ value: String(i), label: `${i + 1}. ${c.title}` }))}
662 onSelect={v => act.goCard(Number(v))}
663 />
664 ) : null
665 const nav = (
666 <Box key="nav" flexDirection="column">
667 {stepper}
668 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
669 <Button key="card-prev" label="‹ Previous" dimColor={at === 0} onPress={() => act.goCard(Math.max(0, at - 1))} />
670 {/* The list takes every column the two buttons leave. */}
671 <Box flexGrow={1} flexShrink={1} flexDirection="column">
672 {jump}
673 </Box>
674 <Button key="card-next" label="Next ›" variant={at < n - 1 ? 'primary' : 'secondary'} dimColor={at >= n - 1} onPress={() => act.goCard(Math.min(n - 1, at + 1))} />
675 </Box>
676 </Box>
677 )
678
679 // 3. The card, framed: what it says, your answer, questions about it.
680 const { content, answer } = cardParts(ctx, card)
681 const color = TYPE_COLOR[card.type]
682 const state = states[at] ?? 'open'
683 const response = !isPending ? md(ui, cardOutcome(card, draft).join('\n'), `outcome-${card.id}`) : null
684 const framed = (
685 <Box key={`card-${card.id}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} gap={1}>
686 <Box flexDirection="column">
687 <Text>
688 <Text color={color} bold>
689 {TYPE_LABEL[card.type].toUpperCase()}
690 </Text>
691 <Text dimColor>
692 {' '}
693 {card.id} · card {at + 1} of {n}
694 {isPending ? ` · ${state === 'done' ? 'answered' : state === 'partial' ? 'started' : 'not answered yet'}` : ''}
695 </Text>
696 </Text>
697 <Box flexDirection="row" justifyContent="space-between" gap={1}>
698 <Text bold>{card.title}</Text>
699 <Button key={`copy-card-${card.id}`} plain dimColor label="⧉ Copy" onPress={p => act.copy(cardText(pack, card, draft), p.surface)} />
700 </Box>
701 </Box>
702 {content}
703 {answer && (
704 <Box flexDirection="column" gap={1}>
705 {label(ui, card.type === 'explainer' || card.type === 'read' ? 'Your note' : 'Your answer', `ah-${card.id}`)}
706 {answer}
707 </Box>
708 )}
709 {response && (
710 <Box flexDirection="column">
711 {label(ui, 'Your response', `rh-${card.id}`)}
712 {response}
713 </Box>
714 )}
715 </Box>
716 )
717
718 // 4. Submitting.
719 const footer = isPending ? (
720 view.isConfirming ? (
721 <Box key="footer" flexDirection="column" gap={1}>
722 <Text color="#FFA726">
723 {n - answered} of {n} cards are not fully answered yet. Submit anyway?
724 </Text>
725 <Box flexDirection="row" gap={1}>
726 <Button key="submit-anyway" variant="primary" label="Submit anyway" onPress={act.submit} />
727 <Button key="keep-reviewing" label="Keep reviewing" onPress={() => act.confirm(false)} />
728 </Box>
729 </Box>
730 ) : (
731 <Box key="footer" flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
732 <Button key="submit" variant={answered === n ? 'primary' : 'secondary'} label="Submit review" onPress={() => (answered === n ? act.submit() : act.confirm(true))} />
733 <Text dimColor>
734 {answered} of {n} answered · saved as you go
735 </Text>
736 </Box>
737 )
738 ) : (
739 <Box key="footer" flexDirection="column">
740 <Text color="#66BB6A">✓ Submitted {when(open.submittedAt)} · outcome.md is beside the pack</Text>
741 <Button key="resend" plain dimColor label="Send the outcome to Claude again" onPress={act.resend} />
742 </Box>
743 )
744
745 return (
746 <Box flexDirection="column" gap={1}>
747 {top}
748 {about}
749 {nav}
750 {framed}
751 {chatBlock(ctx, card)}
752 {footer}
753 </Box>
754 )
755}
756hooks/mdsvg.ts 220 lines1// Markdown drawn as SVG text, so the desktop app shows it in a frame where it can be selected and copied.
2// The app draws its own text elements unselectable; an interactive Svg's text is not. The mod wraps the
3// text itself, by an estimate of glyph widths, and keeps to the Markdown a review uses: paragraphs,
4// headings, lists, quotes, **bold**, *italic*, `code` and links (drawn with their address, not clickable).
5
6const FONT = 14
7const LINE = Math.round(FONT * 1.5)
8const GAP = 8
9
10type Style = 'n' | 'b' | 'i' | 'bi' | 'c' | 'l'
11type Run = { text: string; style: Style }
12type Block = { kind: 'p' | 'h' | 'li' | 'quote'; runs: Run[]; bullet?: string; level?: number }
13
14/** Average glyph width for a style, in ems: generous, so a line is never wider than the box. */
15const EM: Record<Style, number> = { n: 0.53, i: 0.53, l: 0.53, b: 0.58, bi: 0.58, c: 0.62 }
16
17const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
18
19const STYLE = `<style>
20svg{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:${FONT}px}
21text{fill:#1f1e1c}.d text{fill:#6b6a66}.dim{fill:#6b6a66}.b{font-weight:700}.i{font-style:italic}.c{font-family:ui-monospace,Consolas,monospace;fill:#8a4b08}.l{fill:#1565c0;text-decoration:underline}.u{fill:#6b6a66}.bar{fill:#c9c7bf}.h{font-weight:700}
22@media (prefers-color-scheme: dark){text{fill:#ececec}.d text,.dim,.u{fill:#a8a8a4}.c{fill:#ffcc80}.l{fill:#64b5f6}.bar{fill:#4a4946}}
23</style>`
24
25/** Inline Markdown to styled runs. A link becomes its text, then its address in brackets. */
26export function inlineRuns(src: string, base: Style = 'n'): Run[] {
27 const runs: Run[] = []
28 const re = /(\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*)\*|_([^_\s][^_]*)_|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\))/g
29 let at = 0
30 for (let m = re.exec(src); m; m = re.exec(src)) {
31 if (m.index > at) runs.push({ text: src.slice(at, m.index), style: base })
32 if (m[2] !== undefined) runs.push({ text: m[2], style: 'bi' })
33 else if (m[3] !== undefined || m[4] !== undefined) runs.push({ text: (m[3] ?? m[4])!, style: base === 'i' ? 'bi' : 'b' })
34 else if (m[5] !== undefined || m[6] !== undefined) runs.push({ text: (m[5] ?? m[6])!, style: base === 'b' ? 'bi' : 'i' })
35 else if (m[7] !== undefined) runs.push({ text: m[7], style: 'c' })
36 else if (m[8] !== undefined) {
37 runs.push({ text: m[8], style: 'l' })
38 if (m[9] && m[9] !== m[8]) runs.push({ text: ` (${m[9]})`, style: 'n' })
39 }
40 at = m.index + m[0].length
41 }
42 if (at < src.length) runs.push({ text: src.slice(at), style: base })
43 return runs.filter(r => r.text)
44}
45
46/** Block Markdown to blocks; a table is kept as plain rows. */
47export function blocksOf(md: string): Block[] {
48 const blocks: Block[] = []
49 let para: string[] = []
50 const flush = () => {
51 if (para.length) blocks.push({ kind: 'p', runs: inlineRuns(para.join(' ')) })
52 para = []
53 }
54 for (const raw of md.replace(/\r/g, '').split('\n')) {
55 const line = raw.trimEnd()
56 if (!line.trim()) {
57 flush()
58 continue
59 }
60 const h = /^(#{1,6})\s+(.*)$/.exec(line)
61 const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)
62 const q = /^>\s?(.*)$/.exec(line)
63 if (h) {
64 flush()
65 blocks.push({ kind: 'h', runs: inlineRuns(h[2] ?? '', 'b'), level: h[1]!.length })
66 } else if (li) {
67 flush()
68 const marker = li[2]!
69 blocks.push({ kind: 'li', runs: inlineRuns(li[3] ?? ''), bullet: /\d/.test(marker) ? marker : '•', level: Math.min(3, Math.floor((li[1] ?? '').length / 2)) })
70 } else if (q) {
71 flush()
72 const last = blocks[blocks.length - 1]
73 if (last?.kind === 'quote' && (q[1] ?? '').trim()) last.runs.push({ text: ' ', style: 'n' }, ...inlineRuns(q[1] ?? ''))
74 else if ((q[1] ?? '').trim()) blocks.push({ kind: 'quote', runs: inlineRuns(q[1] ?? '') })
75 } else if (/^\|.*\|$/.test(line.trim())) {
76 flush()
77 if (!/^\|[\s:|-]+\|$/.test(line.trim())) blocks.push({ kind: 'p', runs: inlineRuns(line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()).join(' · ')) })
78 } else if (/^(-{3,}|\*{3,})$/.test(line.trim())) flush()
79 else para.push(line.trim())
80 }
81 flush()
82 return blocks
83}
84
85type Piece = { text: string; style: Style; w: number }
86
87/** A glyph's width in ems for a sans-serif face, by shape; a little generous, so text never overruns. */
88function glyph(ch: string): number {
89 if (/[ilj.,'!|:;`]/.test(ch)) return 0.3
90 if (/[ftr()\[\]{}\-"\s]/.test(ch)) return 0.38
91 if (/[mw]/.test(ch)) return 0.82
92 if (/[MW]/.test(ch)) return 0.95
93 if (/[A-Z]/.test(ch)) return 0.7
94 if (/[0-9]/.test(ch)) return 0.58
95 if (/[a-z]/.test(ch)) return 0.56
96 return 0.9 // symbols, emoji, other scripts
97}
98
99const BOLD = 1.08
100
101const widthOf = (text: string, style: Style, size: number) => {
102 if (style === 'c') return text.length * EM.c * size
103 let ems = 0
104 for (const ch of text) ems += glyph(ch)
105 return ems * size * (style === 'b' || style === 'bi' ? BOLD : 1)
106}
107
108/** Wraps runs into lines no wider than `max`; a word wider than a line is cut. */
109function wrapRuns(runs: Run[], max: number, size: number): Piece[][] {
110 const lines: Piece[][] = [[]]
111 let used = 0
112 const put = (text: string, style: Style) => {
113 const w = widthOf(text, style, size)
114 lines[lines.length - 1]!.push({ text, style, w })
115 used += w
116 }
117 for (const run of runs) {
118 for (const token of run.text.split(/(\s+)/)) {
119 if (!token) continue
120 const isSpace = /^\s+$/.test(token)
121 if (isSpace) {
122 if (used > 0) put(' ', run.style)
123 continue
124 }
125 let word = token
126 let w = widthOf(word, run.style, size)
127 if (used + w > max && used > 0) {
128 const line = lines[lines.length - 1]!
129 while (line.length && line[line.length - 1]!.text === ' ') line.pop()
130 lines.push([])
131 used = 0
132 }
133 while (w > max) {
134 let fit = 1
135 while (fit < word.length && widthOf(word.slice(0, fit + 1), run.style, size) <= max) fit++
136 put(word.slice(0, fit), run.style)
137 lines.push([])
138 used = 0
139 word = word.slice(fit)
140 w = widthOf(word, run.style, size)
141 }
142 if (word) put(word, run.style)
143 }
144 }
145 return lines.filter((l, i) => l.length || i === 0)
146}
147
148/** Neighbouring pieces of one style become one run, so a link or a bold phrase is one span. */
149const merged = (pieces: Piece[]) =>
150 pieces.reduce<Piece[]>((out, p) => {
151 const last = out[out.length - 1]
152 if (last && last.style === p.style) last.text += p.text
153 else out.push({ ...p })
154 return out
155 }, [])
156
157const tspans = (pieces: Piece[]) =>
158 merged(pieces)
159 .map(p => (p.style === 'n' ? esc(p.text) : `<tspan class="${p.style === 'bi' ? 'b i' : p.style}">${esc(p.text)}</tspan>`))
160 .join('')
161
162export type TextDrawn = { svg: string; height: number; alt: string }
163
164/** Markdown at a width, as an SVG of selectable text, its height, and the plain text for `alt`. */
165export function markdownSvg(md: string, width: number, isDim = false): TextDrawn {
166 const blocks = blocksOf(md)
167 let y = 0
168 let body = ''
169 const plain: string[] = []
170 blocks.forEach((b, n) => {
171 if (n > 0) y += GAP
172 const size = b.kind === 'h' ? (b.level === 1 ? FONT + 4 : b.level === 2 ? FONT + 2 : FONT + 1) : FONT
173 const lh = Math.round(size * 1.5)
174 const indent = b.kind === 'li' ? 18 + (b.level ?? 0) * 16 : b.kind === 'quote' ? 14 : 0
175 const lines = wrapRuns(b.runs, Math.max(60, width - indent - 4), size)
176 const top = y
177 lines.forEach((line, i) => {
178 y += lh
179 const cls = b.kind === 'h' ? ' class="h"' : ''
180 body += `<text x="${indent}" y="${(y - lh * 0.3).toFixed(1)}"${cls}${b.kind === 'h' ? ` style="font-size:${size}px"` : ''} xml:space="preserve">${tspans(line)}</text>`
181 if (i === 0 && b.kind === 'li') body += `<text x="${indent - 14}" y="${(y - lh * 0.3).toFixed(1)}" class="u">${esc(b.bullet ?? '•')}</text>`
182 })
183 if (b.kind === 'quote') body += `<rect x="0" y="${top + 3}" width="3" height="${Math.max(0, y - top - 4)}" rx="1.5" class="bar"/>`
184 plain.push((b.kind === 'li' ? `${b.bullet} ` : '') + b.runs.map(r => r.text).join(''))
185 })
186 const height = Math.max(LINE, Math.ceil(y + 4))
187 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}<g${isDim ? ' class="d"' : ''}>${body}</g></svg>`
188 return { svg, height, alt: plain.join('\n') }
189}
190
191/**
192 * The owner's question as a chat bubble, sized to its text up to `maxWidth`: drawn as SVG so it is
193 * selectable and follows the light or dark theme.
194 */
195export function bubbleSvg(text: string, maxWidth: number): TextDrawn & { width: number } {
196 const padX = 14
197 const padY = 9
198 const runs: Run[] = [{ text: text.replace(/\s+/g, ' ').trim(), style: 'n' }]
199 const lines = wrapRuns(runs, Math.max(80, maxWidth - padX * 2), FONT)
200 const widest = Math.max(...lines.map(l => l.reduce((w, p) => w + p.w, 0)), 20)
201 const width = Math.min(maxWidth, Math.ceil(widest + padX * 2 + 6))
202 const height = padY * 2 + lines.length * LINE
203 const body = lines
204 .map((l, i) => `<text x="${padX}" y="${(padY + (i + 1) * LINE - LINE * 0.3).toFixed(1)}" xml:space="preserve">${tspans(l)}</text>`)
205 .join('')
206 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}<style>.bub{fill:#f0eee6}@media (prefers-color-scheme: dark){.bub{fill:#393937}}</style><rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="16" class="bub"/>${body}</svg>`
207 return { svg, height, width, alt: text }
208}
209
210/** Three dots breathing in turn while Claude works on an answer; still when motion is reduced. */
211export function thinkingSvg(): TextDrawn & { width: number } {
212 const dot = (cx: number, d: number) => `<circle cx="${cx}" cy="10" r="4" class="t" style="animation-delay:${d}s"/>`
213 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="20" viewBox="0 0 44 20"><style>
214.t{fill:#d97757;animation:rd-breathe 1.2s ease-in-out infinite;transform-box:fill-box;transform-origin:center}
215@keyframes rd-breathe{0%,100%{opacity:.25;transform:scale(.8)}50%{opacity:1;transform:scale(1)}}
216@media (prefers-reduced-motion: reduce){.t{animation:none;opacity:.7}}
217</style>${dot(8, 0)}${dot(22, 0.2)}${dot(36, 0.4)}</svg>`
218 return { svg, height: 20, width: 44, alt: 'Claude is working on an answer' }
219}
220hooks/scenes.ts 486 lines1// Animated SVG for the desktop pane: the desk header, the card strip and explainer scenes.
2// Each drawing ends in its final state when motion is reduced (the animations only lead into it).
3import type { Claim, Scene, SceneItem, Tone } from '../types'
4import type { CardState } from './pack'
5
6export type Drawn = { svg: string; height: number; alt: string }
7
8const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
9
10const STYLE = `<style>
11svg{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
12.ink{fill:#1f1e1c}.dim{fill:#6b6a66}.faint{fill:#a3a19a}.ln{stroke:#d8d6cf}.lnf{fill:#d8d6cf}.card{fill:#ffffff;stroke:#e4e2db}.surf{fill:#f4f3ee}
13.f-subject{fill:#c2185b}.f-context{fill:#6b6a66}.f-risk{fill:#b26a00}.f-question{fill:#6a3fb5}.f-decision{fill:#1565c0}.f-good{fill:#2e7d32}
14.s-subject{stroke:#c2185b}.s-context{stroke:#8d8b85}.s-risk{stroke:#b26a00}.s-question{stroke:#6a3fb5}.s-decision{stroke:#1565c0}.s-good{stroke:#2e7d32}
15.b-subject{fill:#fce4ec}.b-context{fill:#f1f0ec}.b-risk{fill:#fff3e0}.b-question{fill:#efe7fb}.b-decision{fill:#e3f0fc}.b-good{fill:#e6f4e7}
16.g-A{fill:#1b5e20}.g-B{fill:#388e3c}.g-C{fill:#8d8b3a}.g-D{fill:#757575}.g-x{fill:#c62828}
17.on{fill:#2e7d32}.half{fill:#b26a00}.off{fill:#d8d6cf}.cur{stroke:#c2185b}
18@media (prefers-color-scheme: dark){
19.ink{fill:#ececec}.dim{fill:#a8a8a4}.faint{fill:#77756f}.ln{stroke:#3c3b38}.lnf{fill:#3c3b38}.card{fill:#2b2b29;stroke:#3c3b38}.surf{fill:#232321}
20.f-subject{fill:#f06292}.f-context{fill:#a8a8a4}.f-risk{fill:#ffb74d}.f-question{fill:#b39ddb}.f-decision{fill:#64b5f6}.f-good{fill:#81c784}
21.s-subject{stroke:#f06292}.s-context{stroke:#8d8b85}.s-risk{stroke:#ffb74d}.s-question{stroke:#b39ddb}.s-decision{stroke:#64b5f6}.s-good{stroke:#81c784}
22.b-subject{fill:#43202d}.b-context{fill:#2f2f2c}.b-risk{fill:#3d2d14}.b-question{fill:#2f2640}.b-decision{fill:#1b2d40}.b-good{fill:#1f3321}
23.g-A{fill:#66bb6a}.g-B{fill:#81c784}.g-C{fill:#c5c36a}.g-D{fill:#9e9e9e}.g-x{fill:#ef5350}
24.on{fill:#81c784}.half{fill:#ffb74d}.off{fill:#3c3b38}.cur{stroke:#f06292}}
25@keyframes rd-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
26@keyframes rd-fade{from{opacity:0}to{opacity:1}}
27@keyframes rd-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}
28@keyframes rd-rise{from{transform:scaleY(0)}to{transform:scaleY(1)}}
29@keyframes rd-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
30@keyframes rd-pop{0%{transform:scale(0)}70%{transform:scale(1.18)}100%{transform:scale(1)}}
31@keyframes rd-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
32@keyframes rd-pulse{0%,100%{opacity:1}50%{opacity:.35}}
33.in{animation:rd-in .55s cubic-bezier(.2,.7,.2,1) both}
34.fade{animation:rd-fade .5s ease both}
35.grow{transform-box:fill-box;transform-origin:left center;animation:rd-grow .9s cubic-bezier(.3,.7,.2,1) both}
36.rise{transform-box:fill-box;transform-origin:center bottom;animation:rd-rise .7s cubic-bezier(.3,.7,.2,1) both}
37.draw{stroke-dasharray:1;animation:rd-draw .7s ease-out both}
38.pop{transform-box:fill-box;transform-origin:center;animation:rd-pop .5s ease-out both}
39.bob{animation:rd-bob 2.8s ease-in-out infinite}
40.pulse{animation:rd-pulse 1.6s ease-in-out infinite}
41@media (prefers-reduced-motion: reduce){*{animation:none!important}}
42</style>`
43
44const svgDoc = (w: number, h: number, body: string) =>
45 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${STYLE}${body}</svg>`
46
47const delay = (s: number) => `style="animation-delay:${s.toFixed(2)}s"`
48const toneOf = (t?: Tone): Tone => t ?? 'context'
49
50/** Greedy word wrap to an approximate number of characters per line. */
51export function wrap(text: string, perLine: number, maxLines = 6): string[] {
52 const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
53 const lines: string[] = []
54 let line = ''
55 for (const w of words) {
56 if (!line) line = w
57 else if ((line + ' ' + w).length <= perLine) line += ' ' + w
58 else {
59 lines.push(line)
60 line = w
61 }
62 if (lines.length === maxLines) break
63 }
64 if (line && lines.length < maxLines) lines.push(line)
65 if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) lines[maxLines - 1] = (lines[maxLines - 1] ?? '').replace(/.{0,2}$/, '…')
66 return lines.map(l => (l.length > perLine + 6 ? l.slice(0, perLine + 4) + '…' : l))
67}
68
69const textLines = (x: number, y: number, lines: string[], size: number, cls: string, lh = 1.3, weight = 400, anchor = 'start') =>
70 lines
71 .map((l, i) => `<text x="${x}" y="${(y + i * size * lh).toFixed(1)}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" class="${cls}">${esc(l)}</text>`)
72 .join('')
73
74/** A claim chip: id and grade, the claim's text as its tooltip. */
75function badge(x: number, y: number, id: string | undefined, claims: Map<string, Claim>, anchor: 'start' | 'end' = 'end'): string {
76 if (!id) return ''
77 const c = claims.get(id)
78 const label = `${id}${c?.grade ? ` ${c.grade}` : ''}`
79 const w = 9 + label.length * 6.2
80 const x0 = anchor === 'end' ? x - w : x
81 const cls = c?.grade && c.grade !== 'D' ? `g-${c.grade}` : c?.grade === 'D' ? 'g-D' : 'g-x'
82 const tip = c ? `${c.text}${c.source ? ` (${c.source})` : ''}` : 'Claim not listed'
83 return `<g><title>${esc(tip)}</title><rect x="${x0}" y="${y}" width="${w}" height="15" rx="7.5" class="${cls}"/><text x="${x0 + w / 2}" y="${y + 11}" font-size="10" font-weight="600" text-anchor="middle" fill="#fff">${esc(label)}</text></g>`
84}
85
86// ---------- the desk ----------
87
88/** The list's header: a stack of review cards, the pending count and the share answered. */
89export function deskHeaderSvg(w: number, pending: number, reviewed: number, answered: number, cards: number): Drawn {
90 const share = cards ? answered / cards : 0
91 const stack = [0, 1, 2]
92 .map(i => {
93 const x = 18 + i * 7
94 const y = 18 - i * 5
95 const tone = ['decision', 'question', 'subject'][i]
96 return `<g ${delay(0.08 * i)} class="in"><g class="${i === 2 && pending ? 'bob' : ''}"><rect x="${x}" y="${y}" width="46" height="58" rx="6" class="card"/><rect x="${x}" y="${y}" width="5" height="58" rx="2" class="f-${tone}"/><rect x="${x + 11}" y="${y + 12}" width="26" height="4" rx="2" class="lnf"/><rect x="${x + 11}" y="${y + 21}" width="20" height="4" rx="2" class="lnf"/><rect x="${x + 11}" y="${y + 30}" width="24" height="4" rx="2" class="lnf"/></g></g>`
97 })
98 .join('')
99 const bubble = pending
100 ? `<g class="pop" ${delay(0.35)}><circle cx="78" cy="16" r="12" class="f-subject"/><text x="78" y="20.5" font-size="12" font-weight="700" text-anchor="middle" fill="#fff">${pending > 99 ? '99+' : pending}</text></g>`
101 : ''
102 const barX = 110
103 const barW = Math.max(60, w - barX - 20)
104 const words = pending ? `${pending} waiting for you` : 'Nothing waiting'
105 const sub = `${reviewed} reviewed${cards ? ` · ${answered} of ${cards} open cards answered` : ''}`
106 const lines = wrap(`${words} · ${sub}`, Math.max(16, Math.floor(barW / 6.4)), 3)
107 const barY = 52 + lines.length * 16 - 4
108 const h = Math.max(92, barY + 22)
109 const body =
110 stack +
111 bubble +
112 `<text x="${barX}" y="32" font-size="17" font-weight="700" class="ink in">Review desk</text>` +
113 `<g class="fade" ${delay(0.2)}>${textLines(barX, 52, lines, 12.5, 'dim', 1.3)}</g>` +
114 `<rect x="${barX}" y="${barY}" width="${barW}" height="6" rx="3" class="surf"/>` +
115 (share > 0 ? `<rect x="${barX}" y="${barY}" width="${(barW * share).toFixed(1)}" height="6" rx="3" class="f-good grow" ${delay(0.3)}/>` : '')
116 return { svg: svgDoc(w, h, body), height: h, alt: `Review desk: ${words}; ${sub}.` }
117}
118
119/** One numbered step per card: a tick when answered, amber when begun, the current one ringed. */
120export function stepperSvg(w: number, states: CardState[], current: number): Drawn {
121 const n = states.length
122 const done = states.filter(s => s === 'done').length
123 const alt = `Card ${current + 1} of ${n}; ${done} answered.`
124 const roomy = (w - 28) / Math.max(1, n) >= 30
125 if (!roomy) {
126 // Too many cards for numbers: small dots on a line.
127 const gap = Math.max(7, (w - 24) / Math.max(1, n))
128 const r = Math.max(2.5, Math.min(5, gap / 2 - 1.5))
129 const dots = states
130 .map((s, i) => {
131 const cx = 12 + r + i * gap
132 const cls = s === 'done' ? 'on' : s === 'partial' ? 'half' : 'off'
133 return (i === current ? `<circle cx="${cx}" cy="14" r="${r + 3}" fill="none" stroke-width="2" class="cur"/>` : '') + `<circle cx="${cx}" cy="14" r="${r}" class="${cls}"/>`
134 })
135 .join('')
136 return { svg: svgDoc(w, 28, dots), height: 28, alt }
137 }
138 const gap = Math.min(64, (w - 28) / n)
139 const r = 12
140 const x0 = 14 + r
141 const line = n > 1 ? `<line x1="${x0}" y1="20" x2="${x0 + (n - 1) * gap}" y2="20" stroke-width="2" class="ln"/>` : ''
142 const steps = states
143 .map((s, i) => {
144 const cx = x0 + i * gap
145 const isCur = i === current
146 const ring = isCur ? `<circle cx="${cx}" cy="20" r="${r + 4.5}" fill="none" stroke-width="2.5" class="cur"/>` : ''
147 const disc =
148 s === 'done'
149 ? `<circle cx="${cx}" cy="20" r="${r}" class="on"/><path d="M ${cx - 5} ${20.5} l 3.5 3.5 l 6.5 -7" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
150 : s === 'partial'
151 ? `<circle cx="${cx}" cy="20" r="${r}" class="half"/><text x="${cx}" y="24.2" font-size="11.5" font-weight="700" text-anchor="middle" fill="#fff">${i + 1}</text>`
152 : `<circle cx="${cx}" cy="20" r="${r}" class="card" stroke-width="1.5"/><text x="${cx}" y="24.2" font-size="11.5" font-weight="${isCur ? 700 : 500}" text-anchor="middle" class="${isCur ? 'ink' : 'dim'}">${i + 1}</text>`
153 return `<g class="pop" ${delay(0.04 * i)}>${ring}${disc}</g>`
154 })
155 .join('')
156 return { svg: svgDoc(w, 40, line + steps), height: 40, alt }
157}
158
159/** A small ring that fills with the share of cards answered. */
160export function ringSvg(answered: number, total: number): Drawn {
161 const size = 52
162 const r = 21
163 const share = total ? answered / total : 0
164 const c = size / 2
165 const arc =
166 share >= 1
167 ? `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke-width="5" class="s-good"/>`
168 : share > 0
169 ? (() => {
170 const a = share * 2 * Math.PI - Math.PI / 2
171 const x = c + r * Math.cos(a)
172 const y = c + r * Math.sin(a)
173 return `<path d="M ${c} ${c - r} A ${r} ${r} 0 ${share > 0.5 ? 1 : 0} 1 ${x.toFixed(2)} ${y.toFixed(2)}" pathLength="1" fill="none" stroke-width="5" stroke-linecap="round" class="s-good draw"/>`
174 })()
175 : ''
176 const body =
177 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke-width="5" class="ln"/>` +
178 arc +
179 `<text x="${c}" y="${c + 4.5}" font-size="13" font-weight="700" text-anchor="middle" class="ink">${answered}/${total}</text>`
180 return { svg: svgDoc(size, size, body), height: size, alt: `${answered} of ${total} cards answered` }
181}
182
183// ---------- explainer scenes ----------
184
185type Box = { x: number; y: number; w: number; h: number }
186
187function itemBox(b: Box, it: SceneItem, claims: Map<string, Claim>, d: number, titlePer: number, textPer: number): string {
188 const tone = toneOf(it.tone)
189 const t = wrap(it.title, titlePer, 3)
190 const body = it.text ? wrap(it.text, textPer, 6) : []
191 return (
192 `<g transform="translate(${b.x},${b.y})"><g class="in" ${delay(d)}>` +
193 `<rect width="${b.w}" height="${b.h}" rx="8" class="b-${tone}"/><rect width="4" height="${b.h}" rx="2" class="f-${tone}"/>` +
194 textLines(14, 21, t, 13.5, 'ink', 1.25, 650) +
195 textLines(14, 21 + t.length * 17 + 4, body, 12, 'dim', 1.35) +
196 badge(b.w - 8, 7, it.claim, claims) +
197 `</g></g>`
198 )
199}
200
201const boxHeight = (it: SceneItem, titlePer: number, textPer: number) =>
202 20 + wrap(it.title, titlePer, 3).length * 17 + (it.text ? 4 + wrap(it.text, textPer, 6).length * 16.2 : 0) + 6
203
204function cardsScene(w: number, items: SceneItem[], claims: Map<string, Claim>): Drawn {
205 const cols = w >= 640 ? Math.min(3, items.length) : w >= 420 ? Math.min(2, items.length) : 1
206 const gap = 12
207 const cw = (w - 24 - gap * (cols - 1)) / cols
208 const tp = Math.floor((cw - 28) / 7.6)
209 const xp = Math.floor((cw - 28) / 6.4)
210 let y = 12
211 let body = ''
212 for (let r = 0; r * cols < items.length; r++) {
213 const row = items.slice(r * cols, r * cols + cols)
214 const rh = Math.max(...row.map(it => boxHeight(it, tp, xp)))
215 row.forEach((it, i) => (body += itemBox({ x: 12 + i * (cw + gap), y, w: cw, h: rh }, it, claims, 0.12 * (r * cols + i), tp, xp)))
216 y += rh + gap
217 }
218 return { svg: svgDoc(w, y, body), height: y, alt: items.map(i => `${i.title}${i.text ? `: ${i.text}` : ''}`).join('. ') }
219}
220
221function listScene(w: number, items: SceneItem[], claims: Map<string, Claim>): Drawn {
222 const per = Math.floor((w - 70) / 7)
223 let y = 14
224 let body = ''
225 items.forEach((it, i) => {
226 const t = wrap(it.title, per, 3)
227 const x = it.text ? wrap(it.text, Math.floor((w - 70) / 6.4), 4) : []
228 const h = t.length * 17 + x.length * 16 + 10
229 body +=
230 `<g transform="translate(14,${y})"><g class="in" ${delay(0.15 * i)}>` +
231 `<circle cx="9" cy="9" r="9" class="f-${toneOf(it.tone)}"/><text x="9" y="13" font-size="10.5" font-weight="700" text-anchor="middle" fill="#fff">${i + 1}</text>` +
232 textLines(28, 13, t, 13.5, 'ink', 1.25, 600) +
233 textLines(28, 13 + t.length * 17, x, 12, 'dim', 1.35) +
234 badge(w - 30, 0, it.claim, claims) +
235 `</g></g>`
236 y += h + 8
237 })
238 return { svg: svgDoc(w, y + 4, body), height: y + 4, alt: items.map((i, n) => `${n + 1}. ${i.title}${i.text ? `: ${i.text}` : ''}`).join(' ') }
239}
240
241function flowScene(w: number, items: SceneItem[], claims: Map<string, Claim>): Drawn {
242 const n = items.length
243 const isRow = w >= 560 && n <= 5
244 if (!isRow) {
245 const bw = w - 60
246 const tp = Math.floor((bw - 28) / 7.6)
247 const xp = Math.floor((bw - 28) / 6.4)
248 let y = 12
249 let body = ''
250 items.forEach((it, i) => {
251 const h = boxHeight(it, tp, xp)
252 body += itemBox({ x: 30, y, w: bw, h }, it, claims, 0.35 * i, tp, xp)
253 if (i < n - 1)
254 body += `<path d="M ${w / 2} ${y + h + 2} L ${w / 2} ${y + h + 20}" pathLength="1" fill="none" stroke-width="2" class="s-context draw" ${delay(0.35 * i + 0.25)}/><path d="M ${w / 2 - 5} ${y + h + 14} L ${w / 2} ${y + h + 21} L ${w / 2 + 5} ${y + h + 14}" fill="none" stroke-width="2" class="s-context fade" ${delay(0.35 * i + 0.5)}/>`
255 y += h + 24
256 })
257 return { svg: svgDoc(w, y, body), height: y, alt: items.map(i => i.title).join(' → ') }
258 }
259 const gap = 34
260 const bw = (w - 24 - gap * (n - 1)) / n
261 const tp = Math.floor((bw - 28) / 7.6)
262 const xp = Math.floor((bw - 28) / 6.4)
263 const h = Math.max(...items.map(it => boxHeight(it, tp, xp)))
264 let body = ''
265 items.forEach((it, i) => {
266 const x = 12 + i * (bw + gap)
267 body += itemBox({ x, y: 14, w: bw, h }, it, claims, 0.35 * i, tp, xp)
268 if (i < n - 1) {
269 const ax = x + bw + 4
270 const ay = 14 + h / 2
271 body += `<path d="M ${ax} ${ay} L ${ax + gap - 8} ${ay}" pathLength="1" fill="none" stroke-width="2" class="s-context draw" ${delay(0.35 * i + 0.25)}/><path d="M ${ax + gap - 14} ${ay - 5} L ${ax + gap - 7} ${ay} L ${ax + gap - 14} ${ay + 5}" fill="none" stroke-width="2" class="s-context fade" ${delay(0.35 * i + 0.5)}/>`
272 }
273 })
274 return { svg: svgDoc(w, h + 28, body), height: h + 28, alt: items.map(i => i.title).join(' → ') }
275}
276
277function timelineScene(w: number, items: SceneItem[], claims: Map<string, Claim>): Drawn {
278 const n = items.length
279 if (w < 520 || n > 7) {
280 let y = 16
281 let body = `<line x1="26" y1="10" x2="26" y2="${10 + n * 54}" stroke-width="2" pathLength="1" class="ln draw"/>`
282 items.forEach((it, i) => {
283 const x = wrap(it.text ?? '', Math.floor((w - 70) / 6.4), 2)
284 body +=
285 `<circle cx="26" cy="${y + 4}" r="7" class="f-${toneOf(it.tone)} pop" ${delay(0.25 * i + 0.2)}/>` +
286 `<g class="in" ${delay(0.25 * i + 0.3)}>${textLines(44, y + 9, [it.title], 13.5, 'ink', 1.2, 650)}${textLines(44, y + 26, x, 12, 'dim')}${badge(w - 12, y - 4, it.claim, claims)}</g>`
287 y += 54
288 })
289 return { svg: svgDoc(w, y, body), height: y, alt: items.map(i => `${i.title}${i.text ? `: ${i.text}` : ''}`).join('; ') }
290 }
291 const step = (w - 60) / Math.max(1, n - 1)
292 const per = Math.max(10, Math.floor(step / 6.6))
293 const h = 170
294 let body = `<line x1="30" y1="85" x2="${w - 30}" y2="85" stroke-width="2" pathLength="1" class="ln draw"/>`
295 items.forEach((it, i) => {
296 const cx = n === 1 ? w / 2 : 30 + i * step
297 const isUp = i % 2 === 0
298 const t = wrap(it.title, per, 2)
299 const x = wrap(it.text ?? '', per + 2, 2)
300 const ty = isUp ? 70 - (t.length + x.length - 1) * 15 : 106
301 body +=
302 `<circle cx="${cx}" cy="85" r="8" class="f-${toneOf(it.tone)} pop" ${delay(0.25 * i + 0.3)}/>` +
303 `<g class="in" ${delay(0.25 * i + 0.4)}>${textLines(cx, ty, t, 12.5, 'ink', 1.2, 650, 'middle')}${textLines(cx, ty + t.length * 15, x, 11, 'dim', 1.3, 400, 'middle')}</g>` +
304 badge(cx + 4, isUp ? 92 : 64, it.claim, claims, 'start')
305 })
306 return { svg: svgDoc(w, h, body), height: h, alt: items.map(i => `${i.title}${i.text ? `: ${i.text}` : ''}`).join('; ') }
307}
308
309function funnelScene(w: number, items: { label: string; value?: string; tone?: Tone; claim?: string }[], claims: Map<string, Claim>): Drawn {
310 const n = items.length
311 const lh = 44
312 const top = Math.min(w - 40, 560)
313 const cx = w / 2
314 let body = ''
315 items.forEach((it, i) => {
316 const w1 = top * (1 - i / (n + 0.6))
317 const w2 = top * (1 - (i + 1) / (n + 0.6))
318 const y = 10 + i * (lh + 4)
319 body +=
320 `<g class="in" ${delay(0.22 * i)}><path d="M ${cx - w1 / 2} ${y} L ${cx + w1 / 2} ${y} L ${cx + w2 / 2} ${y + lh} L ${cx - w2 / 2} ${y + lh} Z" class="b-${toneOf(it.tone ?? (i === n - 1 ? 'subject' : 'context'))}"/>` +
321 `<text x="${cx}" y="${y + lh / 2 + (it.value ? -2 : 5)}" font-size="13" font-weight="650" text-anchor="middle" class="ink">${esc(it.label)}</text>` +
322 (it.value ? `<text x="${cx}" y="${y + lh / 2 + 14}" font-size="11.5" text-anchor="middle" class="dim">${esc(it.value)}</text>` : '') +
323 badge(cx + w1 / 2 - 4, y + 4, it.claim, claims) +
324 `</g>`
325 })
326 const h = 14 + n * (lh + 4)
327 return { svg: svgDoc(w, h, body), height: h, alt: items.map(i => `${i.label}${i.value ? ` ${i.value}` : ''}`).join(' → ') }
328}
329
330function barsScene(w: number, s: Extract<Scene, { kind: 'bars' }>, claims: Map<string, Claim>): Drawn {
331 const labelW = Math.min(220, Math.max(90, w * 0.3))
332 const valW = 74
333 const trackX = labelW + 16
334 const trackW = Math.max(40, w - trackX - valW - 12)
335 const max = s.max ?? Math.max(...s.items.map(i => i.value), s.mark?.value ?? 0, 1)
336 const rh = 34
337 let body = ''
338 s.items.forEach((it, i) => {
339 const y = 12 + i * rh
340 const share = Math.max(0, Math.min(1, it.value / max))
341 body +=
342 `<g class="fade" ${delay(0.12 * i)}>${textLines(labelW, y + 15, wrap(it.label, Math.floor(labelW / 6.6), 1), 12.5, 'ink', 1.2, 500, 'end')}</g>` +
343 `<rect x="${trackX}" y="${y + 3}" width="${trackW}" height="16" rx="4" class="surf"/>` +
344 (share > 0 ? `<rect x="${trackX}" y="${y + 3}" width="${(trackW * share).toFixed(1)}" height="16" rx="4" class="f-${toneOf(it.tone ?? 'decision')} grow" ${delay(0.12 * i + 0.1)}/>` : '') +
345 `<text x="${trackX + trackW + 8}" y="${y + 15.5}" font-size="12.5" font-weight="650" class="ink fade" ${delay(0.12 * i + 0.7)}>${esc(it.display ?? String(it.value))}</text>` +
346 badge(trackX + trackW + valW + 4, y + 3, it.claim, claims)
347 })
348 const h = 16 + s.items.length * rh + (s.mark ? 18 : 0)
349 if (s.mark) {
350 const mx = trackX + trackW * Math.max(0, Math.min(1, s.mark.value / max))
351 body += `<line x1="${mx}" y1="8" x2="${mx}" y2="${12 + s.items.length * rh}" stroke-width="2" stroke-dasharray="4 3" class="s-decision fade" ${delay(0.9)}/>`
352 if (s.mark.label) body += `<text x="${mx}" y="${h - 4}" font-size="11" text-anchor="middle" class="f-decision fade" ${delay(1)}>${esc(s.mark.label)}</text>`
353 }
354 return { svg: svgDoc(w, h, body), height: h, alt: s.items.map(i => `${i.label}: ${i.display ?? i.value}`).join('; ') + (s.mark?.label ? `; ${s.mark.label} at ${s.mark.value}` : '') }
355}
356
357function tilesScene(w: number, items: { value: string; label: string; tone?: Tone; claim?: string }[], claims: Map<string, Claim>): Drawn {
358 const cols = Math.max(1, Math.min(items.length, Math.floor((w - 12) / 150)))
359 const gap = 12
360 const tw = (w - 24 - gap * (cols - 1)) / cols
361 const th = 104
362 let body = ''
363 items.forEach((it, i) => {
364 const x = 12 + (i % cols) * (tw + gap)
365 const y = 10 + Math.floor(i / cols) * (th + gap)
366 const size = Math.max(18, Math.min(34, (tw - 20) / Math.max(3, it.value.length) * 1.6))
367 body +=
368 `<g transform="translate(${x},${y})"><g class="in" ${delay(0.15 * i)}><rect width="${tw}" height="${th}" rx="10" class="b-${toneOf(it.tone ?? 'subject')}"/>` +
369 `<g class="pop" ${delay(0.15 * i + 0.2)}><text x="${tw / 2}" y="${54}" font-size="${size.toFixed(0)}" font-weight="750" text-anchor="middle" class="f-${toneOf(it.tone ?? 'subject')}">${esc(it.value)}</text></g>` +
370 textLines(tw / 2, 76, wrap(it.label, Math.floor(tw / 6.6), 2), 11.5, 'dim', 1.3, 400, 'middle') +
371 badge(tw - 6, 6, it.claim, claims) +
372 `</g></g>`
373 })
374 const h = 20 + Math.ceil(items.length / cols) * (th + gap) - gap
375 return { svg: svgDoc(w, h, body), height: h, alt: items.map(i => `${i.value} ${i.label}`).join('; ') }
376}
377
378function compareScene(w: number, s: Extract<Scene, { kind: 'compare' }>): Drawn {
379 const cols = s.columns.length
380 const labelW = Math.min(200, w * 0.28)
381 const cw = (w - 24 - labelW) / Math.max(1, cols)
382 const per = Math.max(8, Math.floor(cw / 6.5))
383 let y = 12
384 let body = `<g class="fade">${s.columns.map((c, i) => textLines(12 + labelW + i * cw + 8, y + 14, wrap(c, per, 2), 12, 'ink', 1.2, 700)).join('')}</g>`
385 y += 30
386 s.rows.forEach((row, r) => {
387 const cells = row.cells.slice(0, cols).map(c => wrap(c, per, 3))
388 const label = wrap(row.label, Math.floor(labelW / 6.8), 3)
389 const rh = Math.max(label.length, ...cells.map(c => c.length), 1) * 16 + 14
390 const isSubject = row.tone === 'subject'
391 body +=
392 `<g transform="translate(0,${y})"><g class="in" ${delay(0.12 * r + 0.1)}>` +
393 `<rect x="8" y="0" width="${w - 16}" height="${rh}" rx="6" class="${isSubject ? 'b-subject' : r % 2 ? 'surf' : 'b-context'}"/>` +
394 textLines(16, 18, label, 12.5, isSubject ? 'f-subject' : 'ink', 1.28, 650) +
395 cells.map((c, i) => textLines(12 + labelW + i * cw + 8, 18, c, 12, 'ink', 1.33)).join('') +
396 `</g></g>`
397 y += rh + 4
398 })
399 return {
400 svg: svgDoc(w, y + 8, body),
401 height: y + 8,
402 alt: `Comparison of ${s.columns.join(', ')}: ` + s.rows.map(r => `${r.label}: ${r.cells.join(', ')}`).join('; '),
403 }
404}
405
406function matrixScene(w: number, s: Extract<Scene, { kind: 'matrix' }>, claims: Map<string, Claim>): Drawn {
407 const size = Math.min(w - 70, 460)
408 const x0 = (w - size) / 2 + 14
409 const y0 = 14
410 const half = size / 2
411 const q = s.quadrants ?? []
412 const quads = [
413 [x0, y0],
414 [x0 + half, y0],
415 [x0, y0 + half],
416 [x0 + half, y0 + half],
417 ]
418 .map(([x = 0, y = 0], i) => `<rect x="${x + 2}" y="${y + 2}" width="${half - 4}" height="${half - 4}" rx="6" class="${i === 1 ? 'b-subject' : 'surf'} fade" ${delay(0.08 * i)}/>` + (q[i] ? textLines(x + 10, y + 20, wrap(q[i], Math.floor(half / 7), 2), 11.5, 'faint', 1.25, 600) : ''))
419 .join('')
420 const dots = s.dots
421 .map((d, i) => {
422 const cx = x0 + d.x * size
423 const cy = y0 + (1 - d.y) * size
424 return `<g class="pop" ${delay(0.4 + 0.12 * i)}><circle cx="${cx}" cy="${cy}" r="7" class="f-${toneOf(d.tone ?? 'decision')}"/></g><text x="${cx + 11}" y="${cy + 4}" font-size="11.5" font-weight="600" class="ink fade" ${delay(0.5 + 0.12 * i)}>${esc(d.label)}</text>${badge(cx - 9, cy - 24, d.claim, claims, 'start')}`
425 })
426 .join('')
427 const axes =
428 `<text x="${x0 + size / 2}" y="${y0 + size + 22}" font-size="12" text-anchor="middle" class="dim">${esc(s.x)} →</text>` +
429 `<text transform="translate(${x0 - 14},${y0 + size / 2}) rotate(-90)" font-size="12" text-anchor="middle" class="dim">${esc(s.y)} →</text>`
430 const h = y0 + size + 34
431 return { svg: svgDoc(w, h, quads + axes + dots), height: h, alt: `${s.y} against ${s.x}: ` + s.dots.map(d => d.label).join(', ') }
432}
433
434/** Draws one explainer scene at width `w`; the animation replays whenever the step changes. */
435export function sceneSvg(w: number, scene: Scene, claimList: Claim[] = []): Drawn {
436 const claims = new Map(claimList.map(c => [c.id, c]))
437 switch (scene.kind) {
438 case 'cards':
439 return cardsScene(w, scene.items, claims)
440 case 'list':
441 return listScene(w, scene.items, claims)
442 case 'flow':
443 return flowScene(w, scene.items, claims)
444 case 'timeline':
445 return timelineScene(w, scene.items, claims)
446 case 'funnel':
447 return funnelScene(w, scene.items, claims)
448 case 'bars':
449 return barsScene(w, scene, claims)
450 case 'tiles':
451 return tilesScene(w, scene.items, claims)
452 case 'compare':
453 return compareScene(w, scene)
454 case 'matrix':
455 return matrixScene(w, scene, claims)
456 }
457}
458
459/** The same scene for a text surface: what it says, line by line. */
460export function sceneText(scene: Scene): string[] {
461 switch (scene.kind) {
462 case 'cards':
463 case 'list':
464 case 'timeline':
465 return scene.items.map((i, n) => `${scene.kind === 'list' ? `${n + 1}.` : scene.kind === 'timeline' ? '◆' : '▪'} ${i.title}${i.text ? ` — ${i.text}` : ''}${i.claim ? ` [${i.claim}]` : ''}`)
466 case 'flow':
467 return [scene.items.map(i => i.title).join(' → '), ...scene.items.filter(i => i.text).map(i => ` ${i.title}: ${i.text}`)]
468 case 'funnel':
469 return scene.items.map((i, n) => `${' '.repeat(n)}▼ ${i.label}${i.value ? ` (${i.value})` : ''}`)
470 case 'bars': {
471 const max = scene.max ?? Math.max(...scene.items.map(i => i.value), scene.mark?.value ?? 0, 1)
472 const pad = Math.max(...scene.items.map(i => i.label.length))
473 return [
474 ...scene.items.map(i => `${i.label.padEnd(pad)} ${'█'.repeat(Math.max(0, Math.round((i.value / max) * 20)))} ${i.display ?? i.value}`),
475 ...(scene.mark ? [`${''.padEnd(pad)} ┊ ${scene.mark.label ?? 'mark'}: ${scene.mark.value}`] : []),
476 ]
477 }
478 case 'tiles':
479 return scene.items.map(i => `${i.value} ${i.label}`)
480 case 'compare':
481 return [`| | ${scene.columns.join(' | ')} |`, ...scene.rows.map(r => `| ${r.label} | ${r.cells.join(' | ')} |`)]
482 case 'matrix':
483 return [`${scene.y} (up) against ${scene.x} (right):`, ...scene.dots.map(d => `• ${d.label} (${Math.round(d.x * 100)}, ${Math.round(d.y * 100)})`)]
484 }
485}
486types/index.d.ts 258 lines1// review-desk: the review pack format and the pane's state contract.
2
3/** Evidence grade: A primary or official … D inferred or unsourced. */
4export type Grade = 'A' | 'B' | 'C' | 'D'
5
6/** Colour meaning on a card or a scene element (the explainer engine's palette). */
7export type Tone = 'subject' | 'context' | 'risk' | 'question' | 'decision' | 'good'
8
9/** A graded claim a card or a scene element points at by id (`C3`). */
10export type Claim = {
11 id: string
12 text: string
13 grade?: Grade
14 /** Publisher and title, or the id of the note or file that holds the claim. */
15 source?: string
16 url?: string
17 /** ISO date the source was checked. */
18 checked?: string
19}
20
21/** A note or file the pack mirrors: the record, never the pack. */
22export type SourceRef = {
23 id?: string
24 title?: string
25 path?: string
26 /** The note's `updated` date when the pack was built. */
27 updated?: string
28}
29
30export type Option = { id: string; label: string; description?: string }
31
32export type QuestionCard = {
33 type: 'question'
34 id: string
35 title: string
36 /** Markdown: the question, in plain words. */
37 prompt: string
38 /** Markdown: why it matters, what is known. */
39 context?: string
40 options?: Option[]
41 /** The option id Claude recommends, if any. */
42 recommended?: string
43 /** A free-text answer besides the options (default true). */
44 allowOther?: boolean
45 claims?: string[]
46}
47
48export type DecisionCard = {
49 type: 'decision'
50 id: string
51 title: string
52 /** Markdown: what is proposed. */
53 proposal: string
54 rationale?: string
55 alternatives?: { label: string; reason?: string }[]
56 consequences?: string
57 claims?: string[]
58}
59
60export type FindingCard = {
61 type: 'finding'
62 id: string
63 title: string
64 /** Markdown: what is wrong and why it matters. */
65 body: string
66 severity?: 'high' | 'medium' | 'low'
67 /** `drift`: differs from the record; `review`: reviewer judgement. */
68 kind?: 'drift' | 'review'
69 /** Where it is: a slide, a section, a file. */
70 where?: string
71 quote?: string
72 suggestion?: string
73 evidence?: { ref?: string; quote: string }
74 claims?: string[]
75}
76
77export type PlanRow = { id: string; action: string; target: string; summary?: string }
78
79/** A write plan or change list: the owner picks the rows to go ahead with. */
80export type PlanCard = {
81 type: 'plan'
82 id: string
83 title: string
84 intro?: string
85 rows: PlanRow[]
86}
87
88export type SceneItem = { title: string; text?: string; tone?: Tone; claim?: string }
89
90/** An animated diagram, drawn by the mod from data. */
91export type Scene =
92 | { kind: 'cards'; items: SceneItem[] }
93 | { kind: 'flow'; items: SceneItem[] }
94 | { kind: 'list'; items: SceneItem[] }
95 | { kind: 'timeline'; items: SceneItem[] }
96 | { kind: 'funnel'; items: { label: string; value?: string; tone?: Tone; claim?: string }[] }
97 | {
98 kind: 'bars'
99 items: { label: string; value: number; display?: string; tone?: Tone; claim?: string }[]
100 max?: number
101 mark?: { value: number; label?: string }
102 }
103 | { kind: 'tiles'; items: { value: string; label: string; tone?: Tone; claim?: string }[] }
104 | { kind: 'compare'; columns: string[]; rows: { label: string; cells: string[]; tone?: Tone }[] }
105 | {
106 kind: 'matrix'
107 x: string
108 y: string
109 quadrants?: string[]
110 dots: { label: string; x: number; y: number; tone?: Tone; claim?: string }[]
111 }
112
113export type ExplainerStep = {
114 id?: string
115 title: string
116 /** Markdown: why this step matters, not only what it shows. */
117 caption: string
118 scene?: Scene
119 claims?: string[]
120}
121
122export type ExplainerCard = {
123 type: 'explainer'
124 id: string
125 title: string
126 intro?: string
127 steps: ExplainerStep[]
128}
129
130export type ReadSection = {
131 id: string
132 title: string
133 /** Markdown, shown exactly as given. */
134 body: string
135 /** Lines only the owner can confirm ("Your call"). */
136 flags?: string[]
137}
138
139/** A long text read section by section, with a note and a tick per section. */
140export type ReadCard = {
141 type: 'read'
142 id: string
143 title: string
144 intro?: string
145 sections: ReadSection[]
146}
147
148export type Card = QuestionCard | DecisionCard | FindingCard | PlanCard | ExplainerCard | ReadCard
149export type CardType = Card['type']
150
151/** What Claude publishes: one review for the owner, saved as `pack.json`. */
152export type Pack = {
153 schema: 1
154 id: string
155 title: string
156 /** Markdown: what this review is for and what is being asked. */
157 summary?: string
158 /** A short label: "Open questions", "Write plan", "Deck review". */
159 label?: string
160 createdAt: string
161 /** The skill or task that published it. */
162 origin?: string
163 sources?: SourceRef[]
164 claims?: Claim[]
165 cards: Card[]
166}
167
168/** The owner's response to a card, a step or a section (keyed `card` or `card/part`). */
169export type Response = {
170 /** An option id, `other`, or the card's verb: approve, reject, amend, accept, discuss, go, cancel. */
171 choice?: string
172 /** A free answer, an amendment, a reply or a note. */
173 text?: string
174 /** A step understood, a section reviewed. */
175 isDone?: boolean
176 /** Plan rows to go ahead with. */
177 rows?: Record<string, boolean>
178}
179
180/** A question asked from the pane, and its answer. */
181export type Ask = {
182 id: string
183 question: string
184 answer?: string
185 /** The card it was asked about (absent on questions asked before cards kept their own threads). */
186 card?: string
187 /** What it was asked about: a card title and part. */
188 about?: string
189 at: string
190 /** `quick`: answered in the panel by older versions; every question now goes to the session. */
191 via: 'quick' | 'session'
192 isError?: boolean
193 /** When Claude in the session put its answer on the card. */
194 answeredAt?: string
195 /** What a session answer drew on. */
196 sources?: { title: string; url?: string }[]
197}
198
199/** The owner's work on a pack so far, saved as `draft.json` beside it. */
200export type Draft = {
201 responses: Record<string, Response>
202 asks: Ask[]
203 updatedAt: string
204}
205
206export type PackStatus = 'pending' | 'reviewed'
207
208/** One row of the pane's list. */
209export type PackSummary = {
210 id: string
211 title: string
212 label?: string
213 status: PackStatus
214 createdAt: string
215 submittedAt?: string
216 cards: number
217 answered: number
218 types: CardType[]
219 isBroken?: boolean
220}
221
222export type DeskView = {
223 tab: PackStatus
224 /** The pack open in the pane; none shows the list. */
225 openId?: string
226 openStatus?: PackStatus
227 card: number
228 /** The step or section shown, per explainer or read card. */
229 part: Record<string, number>
230 isSummaryOpen: boolean
231 isConfirming: boolean
232 /** Cards whose "Ask about this card" section is open. */
233 askOpen: Record<string, boolean>
234 showAllAsks: boolean
235 message?: string
236}
237
238/** The pack open in the pane, as loaded from disk. */
239export type OpenPack = {
240 pack: Pack
241 draft: Draft
242 status: PackStatus
243 submittedAt?: string
244}
245
246declare module 'claude-code' {
247 interface PluginState {
248 'review-desk': {
249 packs: PackSummary[]
250 view: DeskView
251 open: OpenPack | null
252 /** What is typed in the pane's fields, not yet committed; never drawn from. */
253 typing: Record<string, string>
254 root: string
255 }
256 }
257}
258