# Decision bar

The decision for the item on screen, pinned where a thumb reaches it: put it in AppLayout's `bottomBar`, above a phone's tab bar. It names the item it decides (`title`), so a scroll can't leave the person deciding something they no longer see, and shows the app's actions, none of them focused or drawn as the default.

When `itemId` changes, every action is locked for `changeGuard` milliseconds (half a second unless set), with the new title as the reason, so a tap meant for the last item can't land on the next one; the lock is a safety guard, not motion, and reduced motion doesn't shorten it. The new title is announced once, from the page's shared live region; nothing is announced when the bar first appears. A destructive action carries the app's words for what can and can't be undone, printed under the bar and tied to its button. An action the person can't take says why (`locked`), and `locked` on the bar locks them all, for a decision that can't be made here. Focus is never moved, except to the bar's own title when the button that had it goes away (new actions for a new item).

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

## Install

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

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/decision-bar`.
It brings in: bidi, button, locale, locked, tokens, use-announcer, use-focus-within, utils.

## Example

```tsx
"use client"

import * as React from "react"
import { Bot, Inbox, MessagesSquare, Settings } from "lucide-react"
import { AppLayout } from "@/components/candor/app-layout"
import { DecisionBar } from "@/components/candor/decision-bar"
import { TabBar } from "@/components/candor/tab-bar"

const reports = [
  { id: "r12", title: "Weekly report 12", summary: "The agent drafted the weekly report from 14 runs. Two charts were redrawn after the data refresh." },
  { id: "r13", title: "Weekly report 13", summary: "The agent drafted the weekly report from 9 runs. One section is shorter than usual: 3 runs failed." },
  { id: "q4", title: "Question 4 from the research agent", summary: "Should the summary include last quarter's figures, or only this quarter's?" },
]

// On a phone, the decision for the item on screen sits above the tab bar; Next moves to another item, and the bar
// locks its actions for half a second as the title changes, so a tap meant for the last item can't land on this one.
export default function Example() {
  const [at, setAt] = React.useState(0)
  const [done, setDone] = React.useState<string | null>(null)
  const item = reports[at]!
  return (
    <div className="flex flex-col gap-2">
      <div className="h-[460px] w-[360px] max-w-full overflow-hidden rounded-lg border">
        <AppLayout
          // A phone-sized preview: the tab bar shows below 640 px, so the bar sits above it.
          defaultWidth={360}
          sidebar={<nav aria-label="Places" className="p-3 text-sm">Places</nav>}
          tabBar={
            <TabBar
              destinations={[
                { id: "threads", label: "Threads", icon: MessagesSquare },
                { id: "inbox", label: "Inbox", icon: Inbox, badge: reports.length },
                { id: "agents", label: "Agents", icon: Bot },
                { id: "settings", label: "Settings", icon: Settings },
              ]}
              activeId="inbox"
            />
          }
          bottomBar={
            <DecisionBar
              itemId={item.id}
              title={item.title}
              actions={
                item.id === "q4"
                  ? [{ id: "answer", label: "Answer", locked: "Answer it in the thread: it needs your words." }]
                  : [
                      { id: "accept", label: "Accept" },
                      { id: "return", label: "Send back" },
                      { id: "discard", label: "Discard", destructive: true, undoable: "The draft can be restored for 30 days; the runs it used aren't undone." },
                    ]
              }
              onAction={(action, itemId) => setDone(`${action} on ${itemId}`)}
            />
          }
        >
          <article className="flex flex-col gap-2 overflow-y-auto p-4 text-sm">
            <h3 className="font-semibold">{item.title}</h3>
            <p>{item.summary}</p>
            <button type="button" className="self-start rounded-md border px-2 py-1 text-xs" onClick={() => setAt((at + 1) % reports.length)}>
              Next item
            </button>
          </article>
        </AppLayout>
      </div>
      <p className="text-xs text-muted-foreground" aria-live="polite">
        {done ? `Last decision: ${done}` : "No decision yet."}
      </p>
    </div>
  )
}
```

## <DecisionBar>

Props:

- `itemId: string`: The item the bar decides: when it changes, the actions lock for `changeGuard` and the new title is announced.
- `title: string`: The item's name as the person sees it on the page, drawn as text (a model's title is safe here).
- `actions: DecisionBarAction[]`: The app's actions for this item, in the order drawn. None is pre-selected or styled as the default.
- `onAction: (actionId: string, itemId: string) => void`: Called with the action's id and the `itemId` it was taken on, so the app can check it is the item it expects.
- `changeGuard?: number | undefined` (default `decisionBarChangeGuard`): How long, in milliseconds, the actions stay locked after `itemId` changes. Defaults to 500 (`decisionBarChangeGuard`); 0 turns the guard off. Not shortened under reduced motion: it guards a decision, it isn't an animation.
- `locked?: string | null | undefined`: Why no decision can be made here (a permission, a state the item is in). Given, every action is locked with it.
- `pending?: string | null | undefined`: The id of the action being recorded: its button is busy, and it and the others are locked until it is done.
- `safeArea?: boolean | undefined`: Pad the bar's foot by `env(safe-area-inset-bottom)`, for the foot of a phone's screen. Use it when nothing sits under the bar to clear that space: an AppLayout with `bottomBar` and no `tabBar` (the tab bar pads its own foot), or a bar the app places at the foot itself. Left out, the bar draws as it did and adds no padding. It is the bar's own bottom padding (`py-2`'s, from the theme's spacing) plus the inset, so a `py-*` or `pb-*` in `className` replaces it and the inset with it.
- `className?: string | undefined`: Classes for the bar, merged with Candor's.

## Values

### decisionBarChangeGuard

How long, in milliseconds, DecisionBar's actions stay locked after the item changes, unless `changeGuard` says.

```ts
const decisionBarChangeGuard: 500
```

## Strings (`decisionBar`)

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

- `region`: Decision on {title}
- `deciding`: Deciding {title}
- `changed`: Now deciding {title}
- `guard`: The item just changed to {title}. Check it before you decide.
- `recording`: Recording your decision
- `undoable`: {action}: {undoable}

## Types

### DecisionBarAction

One of DecisionBar's actions: the app's words for it, and whether it can be used now.

```ts
{
  /** Stable for this action; `onAction` is called with it. */
  id: string;
  /** The button's words, the app's ("Approve", "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`: the bar prints those words under it, and
   * the button is drawn in the destructive tone, never as the default.
   */
  destructive: true;
  /** What can and can't be undone after this action, in the app's words ("Can be reversed for 30 days; the email can't be unsent"). */
  undoable: string;
} | {
  /** Not destructive (the default). */
  destructive?: false | undefined;
  /** Only for a destructive action. */
  undoable?: undefined;
})
```

## 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`.
