# Decision review

A mixed queue of decisions reviewed one at a time: approvals, questions, deliveries to accept, whatever kinds the app names (`kinds`). The focused item shows where it is ("{position} of {total}"), the keys that move, its kind, its title, what happens if the person goes ahead, its facts, what the app will check, what happens if the person stops, and the app's actions; beside it (below it when narrow) the whole queue, the focused one marked `aria-current`.

The honesty rules it keeps: nothing is pre-selected, and no action is focused or drawn as the default. J and K (or ↓ and ↑) move between items, and focus goes to the item's heading when the item changes from inside the review; no key decides: an action is taken only by its button. What gets checked is said in future words in the accent tone, with green only for a check the app reports as passed. A fact the app doesn't know says "Not known". The if-you-stop box is the app's words, or says the app hasn't said. An outcome an agent wrote is text, named as the agent's. Money is formatted by the locale in the app's currency, or is the server's text.

With `actionKeys`, a key moves focus to an action's button, which Enter or Space then presses: the key never decides (shown on the button, and in its `aria-keyshortcuts`).

Which item is focused: pass `currentId` and `onCurrentChange` and you hold it; pass `defaultCurrentId` and DecisionReview holds it, from that item (`null`: none at first); pass neither and it holds none: no item is focused until you pass one, and choosing one calls `onCurrentChange` only. An item of an approval kind can hand off to the app's own ApprovalRequest through `detail`.

- Registry name: `decision-review` (`@candor/decision-review`)
- 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/decision-review

## Install

```bash
npx candor-ui@3 add decision-review
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/decision-review`.
It brings in: agent-avatar, bidi, button, chat, data, heading-level, heading-scope, locale, locked, page-state, tokens, use-announcer, use-focus-within, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { FileCheck, Gauge, MessageCircleQuestion } from "lucide-react"
import { DecisionReview, type DecisionReviewItem } from "@/components/candor/decision-review"

const writer = { id: "writer", name: "Writer agent", role: "analyst" }

// The kinds are the app's: Candor draws the label and icon it is given for each key.
const kinds = {
  accept: { label: "Accept work", icon: FileCheck },
  limit: { label: "Raise a limit", icon: Gauge },
  question: { label: "Question", icon: MessageCircleQuestion },
}

const start: DecisionReviewItem[] = [
  {
    id: "d1",
    kind: "accept",
    title: "Weekly report 12",
    outcome: "The report goes to the team's shared folder and the weekly email links to it.",
    outcomeBy: writer,
    requester: writer,
    facts: [
      { id: "runs", label: "Runs used", value: 14 },
      { id: "cost", label: "Cost so far", value: 182.4, currency: "EUR" },
      // The app doesn't know this yet: it says "Not known", never 0.
      { id: "reviewers", label: "Other reviewers", value: null },
    ],
    checks: [
      { id: "totals", label: "The totals will be compared with last month's" },
      { id: "links", label: "Every link will be opened", result: "passed" },
    ],
    ifYouStop: "The draft stays in your inbox; nothing is sent and nothing is charged.",
    actions: [
      { id: "accept", label: "Accept" },
      { id: "return", label: "Send back" },
    ],
  },
  {
    id: "d2",
    kind: "limit",
    title: "Raise the monthly limit for the research agent",
    outcome: "The agent can keep running until the end of the month.",
    requester: "Sam Rivera",
    facts: [
      { id: "now", label: "Limit now", value: 500, currency: "EUR" },
      { id: "asked", label: "Asked for", value: 750, currency: "EUR" },
      { id: "used", label: "Used", value: "€468.20 (from the billing service)" },
    ],
    // No ifYouStop: the box says the app hasn't said what happens.
    actions: [
      { id: "raise", label: "Raise to €750" },
      { id: "decline", label: "Decline", destructive: true, undoable: "You can raise it later; runs stopped meanwhile aren't restarted." },
    ],
  },
  {
    id: "d3",
    kind: "question",
    title: "Include last quarter's figures?",
    outcome: "The summary will cover both quarters.",
    outcomeBy: writer,
    requester: writer,
    ifYouStop: "The summary covers this quarter only.",
    actions: [
      { id: "yes", label: "Include them" },
      { id: "no", label: "This quarter only" },
    ],
  },
]

export default function Example() {
  const [items, setItems] = React.useState(start)
  return (
    <DecisionReview
      items={items}
      kinds={kinds}
      // DecisionReview holds which item is focused, starting from the first.
      defaultCurrentId="d1"
      // A key moves focus to that action's button; Enter or Space then presses it. No key decides.
      actionKeys={{ accept: "a", return: "s", raise: "r", decline: "x" }}
      onAction={(action, id) =>
        setItems((all) => all.map((x) => (x.id === id ? { ...x, state: { kind: "decided", summary: `You chose “${x.actions.find((a) => a.id === action)?.label}”.` } } : x)))
      }
    />
  )
}
```

## <DecisionReview>

Props:

- `items: DecisionReviewItem[]`: The decisions, in the queue's order.
- `kinds?: Record<string, DecisionReviewKind> | undefined`: The app's kinds, by key: each one's label and icon. Looked up by an item's `kind`; one with no entry shows its key.
- `currentId?: string | null | undefined`: The focused item's id, when you hold it (with `onCurrentChange`); null for none.
- `defaultCurrentId?: string | null | undefined`: The item focused first, when DecisionReview holds it. With `currentId` you hold it and this is ignored; with this alone DecisionReview holds it from here, and `null` means it holds it with none focused at first; with neither it holds none and nothing is focused until `currentId` says.
- `onCurrentChange?: ((id: string) => void) | undefined`: Called with the id of the item the person moves to, from the keys or the queue.
- `onAction: (actionId: string, itemId: string) => void`: Called with the action's id and the id of the item it was taken on.
- `changeGuard?: number | undefined` (default `500`): How long, in milliseconds, the actions stay locked after the item shown changes. Defaults to 500, as DecisionBar's; 0 turns the guard off. Not shortened under reduced motion: it guards a decision, it isn't an animation.
- `actionKeys?: Readonly<Record<string, string>> | undefined`: Keys that move focus to an action's button, by the action's `id` (`{ accept: "a", sendBack: "s" }`): pressing the key anywhere in the review focuses that action's button on the item shown, and Enter or Space then presses it. A key only moves focus; it never decides. Each key is one character, shown on its button and named in its `aria-keyshortcuts` ("Plus" for `+`). It is ignored in a field, with Ctrl, Alt or Meta, with Shift on a letter (a key typed with Shift, such as `?`, works), after the app's own handler calls `preventDefault`, and inside the app's composite widgets in `detail`; while the actions are locked (the change guard, a recording) focus still moves, and the button says why it is locked. A key that is J, K or another action's is ignored, with a warning in development. Left out, no key moves to an action.
- `detail?: ((item: DecisionReviewItem) => React.ReactNode) | undefined`: More for the focused item, drawn under its boxes: the app's own full view, such as its ApprovalRequest for an approval. DecisionReview doesn't import one.
- `state?: ListState | undefined`: What the queue is showing: its items (ready, the default), loading, empty or error with the reason, or partial with a note over them. Loading, empty and error draw in place of the review, in its own words.
- `onRetry?: (() => void) | undefined`: Shows Try again when `state` is an error; called when it's pressed. Pass it only when it loads the list again.
- `headingLevel?: HeadingLevel | undefined`: The level of the focused item's heading and the queue's. Defaults to one below the nearest `HeadingScope`, else 2.
- `className?: string | undefined`: Classes for the review, merged with Candor's.

## Strings (`decisionReview`)

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

- `region`: Decisions to review
- `position`: {position} of {total}
- `hintNext`: J
- `hintPrevious`: K
- `hintMove`: {next}/{previous} or ↓/↑ next and previous
- `queue`: All decisions ({count})
- `chooseOne`: Choose a decision from the list to review it.
- `askedBy`: Asked by {name}
- `outcomeBy`: {agent} says
- `notKnown`: Not known
- `checks`: What gets checked
- `checkWill`: Will be checked
- `checkPassed`: Passed
- `checkFailed`: Failed
- `ifYouStop`: If you stop
- `ifYouStopUnknown`: The app hasn’t said what happens if you stop.
- `recording`: Recording your decision
- `guard`: The item just changed to {title}. Check it before you decide.
- `undoable`: {action}: {undoable}
- `decided`: Decided
- `recordingShort`: Recording
- `loading`: Loading decisions
- `empty`: Nothing to decide.
- `error`: The decisions couldn’t be loaded.

## Types

### DecisionReviewKind

How DecisionReview names one kind of decision: the app's word for it, and an icon to go with it.

```ts
{
  /** The kind's name in the app's words ("Approval", "Question"). */
  label: string;
  /** An icon beside the label; the label is always drawn too. */
  icon?: LucideIcon | undefined;
}
```

### DecisionReviewFact

One fact about a decision, drawn as a box: a label and a value the app knows, or "Not known".

```ts
{
  /** Stable within the item. */
  id: string;
  /** The fact's name, in the app's words. */
  label: string;
  /**
   * The value: a number, formatted by the locale (as money with `currency`, or with `format`); text, the server's own
   * words, drawn as they are; or null when the app doesn't know it, drawn as "Not known", never 0 or a blank box.
   */
  value: number | string | null;
  /** With a number, money in this ISO 4217 currency ("EUR"), formatted by the locale. */
  currency?: string | undefined;
  /** With a number and no `currency`, the locale's number options (a unit, a percent, the decimals). */
  format?: Intl.NumberFormatOptions | undefined;
}
```

### DecisionReviewCheck

One check the app runs on a decision: what it checks, and its result once the app reports one.

```ts
{
  /** Stable within the item. */
  id: string;
  /** What will be checked, in future words ("The totals will be compared with last month's"). */
  label: string;
  /**
   * The result the app reports. Left out, the check hasn't run and is drawn as "Will be checked", in the accent tone;
   * green is kept for a check the app reports as passed.
   */
  result?: "passed" | "failed" | undefined;
}
```

### DecisionReviewAction

One of a decision's actions: the app's words for it, and whether it can be taken now.

```ts
{
  /** Stable within the item; `onAction` is called with it. */
  id: string;
  /** The button's words, the app's ("Accept", "Send back"). */
  label: string;
  /** Why the person can't take this action here, now. Given, the button stays focusable and says why (`Locked`). */
  locked?: string | null | undefined;
} & ({
  /** The action deletes, sends or spends something. It needs `undoable`, printed under the actions and tied to its button. */
  destructive: true;
  /** What can and can't be undone after this action, in the app's words. */
  undoable: string;
} | {
  /** Not destructive (the default). */
  destructive?: false | undefined;
  /** Only for a destructive action. */
  undoable?: undefined;
})
```

### DecisionReviewItemState

Where a decision is: open (the default), being recorded after the person chose an action, or decided.

```ts
{
  kind: "open";
} | {
  kind: "recording";
  /** The id of the action being recorded: its button is busy, the others locked. */
  action: string;
} | {
  kind: "decided";
  /** What was decided, in the app's words ("Accepted by you at 10:02"). Drawn in place of the actions. */
  summary: string;
}
```

### DecisionReviewItem

One decision in DecisionReview's queue. Kinds, facts, checks and words are the app's.

```ts
{
  /** Stable for this decision. */
  id: string;
  /** Which kind of decision it is: a key of DecisionReview's `kinds`. A kind with no entry shows this key as it is. */
  kind: string;
  /** The decision's title, drawn as text. */
  title: string;
  /**
   * One sentence saying what happens if the person goes ahead, drawn as text. With `outcomeBy` it is named as that
   * agent's: what an agent wrote is believed, not verified.
   */
  outcome?: string | undefined;
  /** The agent that wrote `outcome`, when an agent did. Left out, the sentence is the app's. */
  outcomeBy?: AgentIdentity | undefined;
  /** The facts, each drawn as a box. */
  facts?: DecisionReviewFact[] | undefined;
  /** What the app will check, drawn in the what-gets-checked box. Left out or empty, the box isn't drawn. */
  checks?: DecisionReviewCheck[] | undefined;
  /**
   * What happens if the person stops (declines, sends back, lets it lapse): what can and can't be undone, in the app's
   * words. Left out or null, the box says the app hasn't said.
   */
  ifYouStop?: string | null | undefined;
  /** The app's actions for it, in the order drawn. None is pre-selected or styled as the default. */
  actions: DecisionReviewAction[];
  /** Who asked: a person's name as the app gives it, or an agent (drawn with its avatar). */
  requester?: string | AgentIdentity | undefined;
  /** Where it is. Defaults to open. */
  state?: DecisionReviewItemState | undefined;
}
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### ListState

Import: `import type { ListState } from "@/lib/candor/data"` (item `data`, https://candor.themethodic.com/docs/data.md)

What a list or table is showing, the same shape as `ChartState` and as page-state's `ListState` (which the free tier ships, so it declares its own rather than import this file): its rows (ready), the shape of rows still coming (loading), nothing and why (empty), the rows it has with a note on what's missing (partial), or why it couldn't load (error). Lists without a `state` show their rows.

```ts
ChartState
```

### HeadingLevel

Import: `import type { HeadingLevel } from "@/lib/candor/heading-level"` (item `heading-level`, https://candor.themethodic.com/docs/heading-level.md)

A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.

```ts
1 | 2 | 3 | 4 | 5 | 6
```

### AgentIdentity

Import: `import type { AgentIdentity } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
{
  id: string;
  /** The displayed name, e.g. "Architect agent". Never derived from model output. */
  name: string;
  role: AgentRole;
  specialty?: string | undefined;
}
```

### ChartState

Import: `import type { ChartState } from "@/lib/candor/data"` (item `data`, https://candor.themethodic.com/docs/data.md)

```ts
{
  kind: "ready";
} | {
  kind: "loading";
}
/** Nothing to plot, and why. */
 | {
  kind: "empty";
  reason: string;
}
/** Some of the range is missing. */
 | {
  kind: "partial";
  note: string;
} | {
  kind: "error";
  reason: string;
}
```

### AgentRole

Import: `import type { AgentRole } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

What an agent does: "assistant", "coder", "reviewer"… The app registers each role's glyph and hue (AgentRolesProvider).

```ts
string
```

## Terms used here

- **Calm level**: What Candor's motion does for a person whose operating system asks for reduced motion: the highest-precedence setting, above the app's own motion level (`none`, `calm`, `subtle` or `expressive`) and the brand file. A theme tunes how a change looks, never when it happens, so nothing depends on seeing the motion. (https://candor.themethodic.com/glossary.md#calm-level)

## 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`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
