# Board

A kanban board of cards a person moves along, by dragging a card's handle, from the keyboard, or from a menu beside the handle. Its columns are the places a card can be: on the keyboard, Space or Enter on the handle picks a card up, up and down move it in its column, left and right move it between columns following the reading direction, Space drops it and Escape puts it back; the menu's Move to another column does the same. It is `ReorderableList` with groups, so every step is announced and `onMove` is called once, with where the card ended.

Each column's head shows its name, its count and its limit in words ("3 of 5"; "6 of 5, over the limit" beside a warning icon), and a "Collapse {column}" button that folds it to its head; a folded column is still a place to move to. The columns sit in one row that scrolls sideways, a named region the keyboard can scroll. Below 640 pixels of board width it shows one column at a time, with a picker of columns above it ("Doing, 3"); moves still go through the menu and the keys, and the board shows the column a card was moved to.

Long columns stay whole: each card is skipped by the browser's layout while it is off screen (`content-visibility: auto`), not taken out of the page, so find in page, the moves menu and screen readers reach every card. Draw cards with `BoardCard`.

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

## Install

```bash
npx candor-ui add board
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/board`.
It brings in: agent-avatar, avatar, bidi, chat, heading-level, heading-scope, initials, link, locale, relative-time, reorderable-list, select, status, status-dot, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { Bug, Wrench } from "lucide-react"
import { Board, BoardCard, type BoardAssignee, type BoardCost, type BoardMove, type BoardRun } from "@/components/candor/board"
import type { AgentIdentity } from "@/lib/candor/chat"
import { SemanticBadge } from "@/components/candor/semantic-badge"

const builder: AgentIdentity = { id: "builder", name: "Build agent", role: "coder" }
const reviewer: AgentIdentity = { id: "reviewer", name: "Review agent", role: "reviewer" }
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString()

type Ticket = {
  id: string
  ref: string
  title: string
  column: string
  kind: "fix" | "change"
  risk: "low" | "medium"
  assignee?: BoardAssignee | undefined
  run?: BoardRun | undefined
  cost?: BoardCost | undefined
  waiting?: string | undefined
  movedBy?: Omit<BoardMove, "undo"> & { from: string } | undefined
}

const start: Ticket[] = [
  { id: "48", ref: "#48", title: "Require a source on every new lead", column: "understanding", kind: "change", risk: "low", assignee: { kind: "agent", agent: builder }, run: { status: "running", lastHeardAt: minutesAgo(0.2) }, cost: { value: "$0.30", kind: "spent" } },
  { id: "51", ref: "#51", title: "Export fails for reports over 10,000 rows", column: "understanding", kind: "fix", risk: "medium", assignee: { kind: "person", name: "Dana Ortiz" }, cost: { value: "$1.20", kind: "estimated" } },
  { id: "52", ref: "#52", title: "Retire the old discount rules", column: "understanding", kind: "change", risk: "low", assignee: { kind: "agent", agent: builder }, run: { status: "running", lastHeardAt: minutesAgo(9) } },
  { id: "44", ref: "#44", title: "Add a region field to accounts", column: "waiting", kind: "change", risk: "medium", assignee: { kind: "agent", agent: reviewer }, waiting: "Waiting for you: approve the field’s visibility", cost: { value: "$0.85", kind: "spent" }, movedBy: { agent: reviewer, at: minutesAgo(4), from: "building" } },
  { id: "45", ref: "#45", title: "Fix the duplicate welcome email", column: "waiting", kind: "fix", risk: "low", assignee: { kind: "person", name: "Mei Chen" }, waiting: "Waiting for you: choose a send time" },
  { id: "40", ref: "#40", title: "Make the lead source required", column: "done", kind: "change", risk: "low", assignee: { kind: "agent", agent: builder }, cost: { value: "$0.42", kind: "spent" } },
  { id: "41", ref: "#41", title: "Index the activity table by date", column: "done", kind: "change", risk: "low", assignee: { kind: "person", name: "Jonas Berg" } },
]

export default function Example() {
  const [tickets, setTickets] = React.useState(start)
  const move = (key: string, to: string, index: number) =>
    setTickets((all) => {
      const card = all.find((c) => c.id === key)
      if (!card) return all
      const rest = all.filter((c) => c.id !== key)
      const inTo = rest.filter((c) => c.column === to)
      const at = index < inTo.length ? rest.indexOf(inTo[index]!) : rest.length
      // A person's move clears an agent's earlier one.
      return [...rest.slice(0, at), { ...card, column: to, movedBy: undefined }, ...rest.slice(at)]
    })
  return (
    <Board<Ticket>
      label="Tickets"
      // Building takes at most two; Understanding has passed its limit of two, and the head says so in words.
      columns={[
        { id: "understanding", label: "Understanding", limit: 2 },
        { id: "building", label: "Building", limit: 2 },
        { id: "waiting", label: "Waiting for you" },
        { id: "done", label: "Done" },
      ]}
      defaultCollapsed={["done"]}
      cards={tickets}
      keyOf={(c) => c.id}
      labelOf={(c) => c.title}
      columnOf={(c) => c.column}
      onMove={move}
      renderCard={(c) => (
        <BoardCard
          title={c.title}
          reference={c.ref}
          icon={c.kind === "fix" ? Bug : Wrench}
          chips={<SemanticBadge vocabulary="risk" value={c.risk} size="sm" />}
          assignee={c.assignee}
          run={c.run}
          cost={c.cost}
          waiting={c.waiting}
          href={`#ticket-${c.id}`}
          movedBy={
            c.movedBy && {
              agent: c.movedBy.agent,
              at: c.movedBy.at,
              // Undo puts it back where the agent took it from.
              undo: () => {
                const from = c.movedBy!.from
                setTickets((all) => all.map((t) => (t.id === c.id ? { ...t, column: from, movedBy: undefined } : t)))
              },
            }
          }
        />
      )}
    />
  )
}
```

## <Board>

Props:

- `columns: BoardColumn[]`: The columns, in order.
- `cards: T[]`: Every card, in order within its column.
- `keyOf: (card: T) => string`: A stable key for a card.
- `labelOf: (card: T) => string`: What a card is called, for its handle ("Move Fix the export") and the announcements. Drawn and read as text.
- `columnOf: (card: T) => string`: The id of the column a card is in.
- `renderCard: (card: T, state: { grabbed: boolean }) => React.ReactNode`: Draws a card (usually a `BoardCard`); `grabbed` is true while it is being moved.
- `onMove: (key: string, toColumn: string, index: number) => void`: Called once when a move is dropped: the card's key, the column it went to and its index there.
- `label: string`: What the board is, for screen readers: "Tickets".
- `locked?: string | null | undefined`: Why cards can't be moved now. Every handle and menu stays reachable and says why. `null` or left out: unlocked.
- `collapsed?: string[] | undefined`: The ids of the folded columns, when you keep them.
- `defaultCollapsed?: string[] | undefined`: The columns folded at first, when the board keeps them itself.
- `onCollapsedChange?: ((collapsed: string[]) => void) | undefined`: Called with the folded column ids when the person folds or opens one.
- `headingLevel?: HeadingLevel | undefined`: The level of each column's heading, so the board fits under the page's own headings. Defaults to one below the nearest `HeadingScope`, else 2 (`<h2>`). A `BoardCard`'s title is one below its column's.
- `className?: string | undefined`

## <BoardCard>

One card on a `Board`: its title (the card's main control, a link with `href` or a button with `onOpen`), a reference in mono ("#48"), chips from the app's vocabulary, who it is assigned to, the run working on it, what it has cost, whether it waits on the person, and a move an agent made.

An agent is drawn as an agent (`AgentAvatar` with its name), never with initials that would read as a person. The run's dot pulses only while the run has been heard from recently, so a stalled run stops looking alive. A cost says whether it is spent so far or estimated. A card waiting on the person has a band at the top in words ("Waiting for you"). A move an agent made says so ("Moved by Build agent 5 minutes ago"), with Undo while the app offers it.

Props:

- `title: string`: What the card is about. It may be an agent's words: drawn as plain text, isolated.
- `reference?: string | undefined`: The card's reference ("#48"), drawn in mono.
- `icon?: LucideIcon | undefined`: An icon for the card's kind, beside the reference.
- `chips?: React.ReactNode | undefined`: Chips from the app's vocabulary, such as `SemanticBadge`s.
- `assignee?: BoardAssignee | undefined`: Who it is assigned to.
- `run?: BoardRun | undefined`: The run working on it.
- `cost?: BoardCost | undefined`: What it has cost, spent so far or estimated.
- `waiting?: string | undefined`: Why it waits on the person, in words ("Waiting for you"), drawn as a band at the top.
- `movedBy?: BoardMove | undefined`: A move an agent made, with Undo while the app offers it.
- `href?: string | undefined`: Where the card opens: the title is a link.
- `onOpen?: (() => void) | undefined`: Opens the card, when it isn't at an address: the title is a button. `href` wins when both are given.
- `headingLevel?: HeadingLevel | undefined`: The level of the title's heading. Defaults to one below the nearest `HeadingScope` (on a `Board`, one below its column's heading), else 2 (`<h2>`).
- `className?: string | undefined`

## Strings (`board`)

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

- `ofLimit`: {count} of {limit}
- `overLimit`: {count} of {limit}, over the limit
- `column`: Column
- `columnOption`: {column}, {count}
- `unplaced`: { one: "{count} card in no column", other: "{count} cards in no column" }
- `columns`: Columns of {label}

## Strings (`boardCard`)

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

- `spent`: {value} spent so far
- `estimated`: {value} estimated
- `assignedTo`: Assigned to {name}
- `movedBy`: Moved by {agent} {when}
- `undo`: Undo
- `undoMove`: Undo the move of {title}

## Types

### BoardColumn

A column of a Board: its id, its name, and how many cards it should hold at most.

```ts
{
  id: string;
  /** The column's name, drawn as text. */
  label: string;
  /** How many cards it should hold. Past it the column says so in words; nothing is stopped. */
  limit?: number | undefined;
}
```

### BoardAssignee

Who a card is assigned to: a person, drawn with initials, or an agent, drawn as the agent it is.

```ts
{ kind: "person"; name: string } | { kind: "agent"; agent: AgentIdentity }
```

### BoardRun

The run working on a card: its status and when it was last heard from.

```ts
{
  status: RunStatus;
  /** When the run was last heard from (ISO date); its dot stops pulsing once it has gone quiet. */
  lastHeardAt?: string | null | undefined;
}
```

### BoardCost

What a card has cost: spent so far, or an estimate. `kind` is required, so a figure always says which.

```ts
{ value: string; kind: "spent" | "estimated" }
```

### BoardMove

A move an agent made: who, when, and how to undo it while the app offers that.

```ts
{
  agent: AgentIdentity;
  /** When it moved the card (ISO date). */
  at: string;
  /** Puts the card back. Left out once the app no longer offers it. */
  undo?: (() => void) | undefined;
}
```

## Types from other items

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

### 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;
}
```

### RunStatus

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

The status vocabulary every Candor agent component shares. Components take plain data in these shapes rather than an SDK's message objects, so they work with your own LLM drivers, the Vercel AI SDK or anything else; adapters map an SDK's parts onto them.

```ts
"idle" // not started
 | "queued" // accepted, waiting for a worker
 | "running" // an agent is working on it now
 | "waiting" // stopped to ask a person (approval, question)
 | "blocked" // can't start: something it depends on failed or is waiting
 | "succeeded" | "failed" | "cancelled" | "skipped"
```

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

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