# Review canvas

The governed review surface (Candor's ReviewCanvas): the plan on one side; select a step and its log, diff, blast radius and reasoning appear on the other. The approval sits under the evidence, so a decision is made looking at exactly what it covers. Tabs with nothing for the step say so instead of disappearing.

- Registry name: `review-canvas` (`@candor/review-canvas`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/review-canvas

## Install

```bash
npx candor-ui add review-canvas
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/review-canvas`.
It brings in: approval-request, blast-radius, chat, locale, log-stream, metadata-diff, plan, reasoning, tabs, work, utils.

## Example

```tsx
"use client"

import type { AgentIdentity, ReasoningEntry } from "@/lib/chat"
import type { ApprovalRequest, BlastRadiusScope, FileDiff, LogItem, PlanStep } from "@/lib/work"
import { ReviewCanvas } from "@/components/review-canvas"

const dev: AgentIdentity = { id: "a-dev", name: "Dev agent", role: "dev" }
const migration = { kind: "migration", name: "0142_add_customer_region", label: "Add customers.region" }
const header = { kind: "component", name: "web/src/customers/CustomerHeader.tsx", label: "CustomerHeader" }

const steps: PlanStep[] = [
  { id: "s1", title: "Add the region column", status: "succeeded", agent: dev, duration: "14s", touches: [migration] },
  { id: "s2", title: "Show it on the customer page", status: "succeeded", agent: dev, duration: "9s", dependsOn: ["s1"], touches: [header] },
  { id: "s3", title: "Backfill from billing country", status: "running", agent: dev, dependsOn: ["s1"] },
  { id: "s4", title: "Test on Staging", status: "blocked", agent: { id: "a-qa", name: "QA agent", role: "qa" }, dependsOn: ["s2", "s3"] },
]

const logs: LogItem[] = [
  { kind: "line", id: "l1", at: "10:42:01", severity: "info", actor: "Dev agent", stepId: "s1", message: "Running migration 0142 on Staging" },
  { kind: "line", id: "l2", at: "10:42:14", severity: "info", actor: "staging", stepId: "s1", message: "Migration applied: 1 column, 1 index" },
  { kind: "line", id: "l3", at: "10:42:15", severity: "info", actor: "Dev agent", stepId: "s2", message: "Updating CustomerHeader" },
  { kind: "line", id: "l4", at: "10:42:33", severity: "info", actor: "Dev agent", stepId: "s3", message: "Backfill: batch 1 of 19 (1,000 customers)" },
  { kind: "line", id: "l5", at: "10:43:44", severity: "warn", actor: "staging", stepId: "s3", message: "Lock timeout on customers id 48213. Retrying batch 12." },
]

const diffs: Record<string, FileDiff[]> = {
  s1: [
    {
      path: "db/migrations/0142_add_customer_region.sql",
      entity: migration,
      change: "create",
      language: "sql",
      hunks: [{ lines: [{ kind: "add", text: "ALTER TABLE customers ADD COLUMN region region;", newNo: 1 }] }],
    },
  ],
  s2: [
    {
      path: "web/src/customers/CustomerHeader.tsx",
      entity: header,
      change: "update",
      language: "tsx",
      hunks: [
        {
          lines: [
            { kind: "remove", text: '<Fact label="Partner tier" value={customer.partnerTierOld} />', oldNo: 44 },
            { kind: "add", text: '<Fact label="Region" value={regionLabel(customer.region)} />', newNo: 44 },
          ],
        },
      ],
    },
  ],
}

const reach: Record<string, BlastRadiusScope> = {
  s1: { kind: "measured", unread: [], components: [{ entity: migration, change: "create", diff: { added: 1, removed: 0 } }] },
  s2: { kind: "measured", unread: ["Mobile app screens"], components: [{ entity: header, change: "update", diff: { added: 1, removed: 1 } }] },
  s3: { kind: "unknown", reason: "A backfill touches rows, not code. Which rows change depends on billing country at run time." },
}

const reasoning: Record<string, ReasoningEntry[]> = {
  s3: [
    { id: "x1", title: "Map billing country to region", basis: "verified", evidence: "Data model notes v3 lists the country table" },
    { id: "x2", title: "Use other for countries not in the table", basis: "assumed", assumption: "The team prefers a catch-all to leaving region empty" },
  ],
}

const approval: ApprovalRequest = {
  id: "ap1",
  title: "Add region to customers",
  summary: "A region column on customers, shown on the customer page and backfilled from billing country.",
  requester: { agent: { id: "a-orch", name: "Keel", role: "orchestrator" } },
  environment: "Acme web",
  production: true,
  artifactHash: "9f2c4e81b7a03d55c6e1f0a9b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9",
  impact: { components: [migration, header], rows: "unknown" },
  rollback: { available: true, how: "Revert the pull request and run the migration's down step." },
  createdAt: new Date(Date.now() - 25 * 60_000).toISOString(),
  expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
  state: "pending",
}

export default function Example() {
  return (
    <ReviewCanvas
      title="Add region to customers"
      steps={steps}
      logs={logs}
      diffs={diffs}
      reach={reach}
      reasoning={reasoning}
      approval={approval}
      onApprove={() => {}}
      onReject={() => {}}
      live
    />
  )
}
```

## <ReviewCanvas>

Props:

- `title: string`: The plan's title.
- `steps: PlanStep[]`: The plan's steps; the first running, waiting or failed one is selected to start.
- `logs: LogItem[]`: The run's log, filtered to the selected step.
- `diffs?: Record<string, FileDiff[]> | undefined` (default `{}`): File diffs per step id. A step without any says it changes no metadata.
- `reach?: Record<string, BlastRadiusScope> | undefined` (default `{}`): Blast radius per step id. A step without one says reach isn't measured.
- `reasoning?: Record<string, ReasoningEntry[]> | undefined` (default `{}`): The agent's reasoning per step id. A step without any says none was recorded.
- `approval?: ApprovalData | undefined`: The approval request, shown under the evidence.
- `onApprove?: ((hash: string) => void) | undefined`: Called on approve with the change hash.
- `onReject?: ((hash: string) => void) | undefined`: Called on reject with the change hash.
- `live?: boolean | undefined` (default `false`): Shows the log as live while the selected step is running. Defaults to false.
- `className?: string | undefined`

## Strings (`reviewCanvas`)

The fixed words, English by default. Override any of them under `reviewCanvas` in `<LocaleProvider strings={{ reviewCanvas: { … } }}>`; the table is exported as `reviewCanvasStrings`.

- `evidenceFor`: Evidence for {step}
- `evidence`: Evidence
- `selectStep`: Select a step
- `log`: Log
- `diff`: Diff
- `reach`: Reach
- `why`: Why
- `noDiff`: This step doesn’t change any metadata.
- `noReach`: Reach isn’t measured for this step.
- `noReasoning`: The agent didn’t record reasoning for this step.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
