SLOPSHOPPER

review-desk

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-desk
│ ┃ Review desk ✕ › fix the failing auth test and add an audit log call │ ┃ Review desk │ ┃ Nothing waiting · 0 reviewed ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Waiting · 0 ] [ Reviewed · 0 ] ↻ Refresh ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing is waiting for review. Ask Claude to ⏺ Bash(bun test) │ ┃ put something up, for example: "put the open ⎿ 3 pass, 1 fail │ ┃ questions up for review" or "send this write │ ┃ plan to the review desk". ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Saved in Reviews/pending/ ✻ Worked for 42s · done 4:20 PM │ │ › /reviews │ ⎿ review-desk: Review desk: nothing waiting. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review desk
Review desk Nothing waiting · 0 reviewed [ Waiting · 0 ] [ Reviewed · 0 ] ↻ Refresh 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". Saved in Reviews/pending/
README

review-desk

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.

What it does

  • A list of reviews. Pending reviews show how many cards you have answered; submitted ones stay under Reviewed and open read-only with your responses. The status line shows how many reviews are waiting.
  • Six kinds of card, all in one review if needed:
CardYou
Questionpick an option (Claude's recommended one is marked) or write an answer
Decisionapprove, approve with changes, or reject, with a comment
Findingaccept, reject or discuss, with a reply; quotes, severity and "the record says" evidence are shown
Planuntick rows you do not want, then go ahead or cancel
Explainerstep through animated scenes with captions and graded claims, ticking Got it
Read-throughread a long text section by section, with "Your call" flags, a note and a Reviewed tick each
  • Animated explainers. Claude sends the scene as data and the desk draws it, on the desktop, as animated SVG: cards, flow, list, timeline, funnel, bars with a threshold, number tiles, a comparison table and a 2 × 2 matrix. Claim chips carry their grade (A–D), and hovering one shows the claim. In the terminal the same scene is drawn as text.
  • Ask Claude about the card in front of you. Under each card is a short chat, drawn like Claude Desktop's: your questions in bubbles on the right, Claude's answers on the left, and a box to type in. Each question goes to Claude in the session, which researches it with everything the session has (the web, your project, its notes) and puts the answer back under the card, with its sources. Each card keeps its own conversation.
  • One card at a time. A review opens on a numbered stepper (a tick for each card answered), Previous and Next around a jump list, and the current card in a frame coloured by its type: what the card says, its evidence, then Your answer. Submitting sits at the bottom.
  • Copy. On the desktop, card text can be selected and copied like any text; a link in it shows its address in brackets so you can copy that too. Lists made mostly of links (evidence, sources) stay clickable instead. Each card and each answer also has a Copy button that puts the whole thing on your clipboard as Markdown.
  • Saved as you go. Every answer is written to draft.json straight away. Close the panel, or end the session, and carry on later.
  • Submit, and Claude applies it. Submitting writes 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.

Install

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.

Commands and tools

NameWhat it does
/reviewsOpens 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".

Settings

SettingDefaultWhat it does
Reviews folderReviewsThe folder in the project, relative to its root, that holds pending/ and reviewed/
Open the desk when Claude publishes a reviewonOpens the panel on the new review, unless you are in the middle of another one
Hand submitted reviews to ClaudeonOn submit, queues a prompt asking Claude to apply the outcome
Selectable textonOn 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

How it works

<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
  • Claude publishes with 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.
  • The panel draws from those files, with the view state in $.state so it survives the mod reloading.
  • On submit, the outcome is written beside the pack, and the folder is moved to 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.
  • The files mirror the record and are never the record. The prompt handed back to Claude tells it to apply each response in the source note or file, following the project's own rules, and to confirm before writing.

The pack format

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://…" }]
}

Known limits

  • Desktop and terminal. Animated scenes draw on the desktop. The terminal shows them as text, and the mobile app, which has no text fields yet, shows reviews without the answer boxes.
  • An answer arrives when Claude has finished its turn. Your question is sent to the session as a prompt (you see it in the conversation), and Claude answers once it is free; a question asked while Claude is busy waits its turn.
  • Selectable text is drawn by the mod. The app's own text elements cannot be selected, so on the desktop card text is drawn as SVG text. It wraps by an estimate of letter widths, keeps simple Markdown (bold, italic, code, lists, quotes, headings), and links in it are not clickable: their addresses show in brackets. Blocks that are mostly links stay the app's own Markdown, clickable but not selectable. Turn Selectable text off to draw everything the app's way.
  • The Copy buttons use the system clipboard on the desktop, as the desktop app has no clipboard path for plugins yet: PowerShell Set-Clipboard on Windows, pbcopy on macOS, wl-copy or xclip on Linux.
  • Text you type is saved when you press Enter or move on. Typing and then closing the window straight away can lose the last field.
  • Reviewed items are read-only. To review something again, ask Claude to publish it again; the new review is filed separately.
  • The function-hook plugin API is early access and may change between Claude Code releases. Built and tested against Claude Code 2.1.296.
Source 7 files
hooks/register.tsx 583 lines
1import { 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}
583
hooks/disk.ts 34 lines
1// 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}
34
hooks/pack.ts 610 lines
1// 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}
610
hooks/view.tsx 756 lines
1// 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}
756
hooks/mdsvg.ts 220 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
220
hooks/scenes.ts 486 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
486
types/index.d.ts 258 lines
1// 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