# Eval table

Evaluation runs side by side: scores per run, the diff against the baseline, failing rows first, and the full output one keypress away.

Tier: pro. Type: component. Category: data. Tags: evals, table, keyboard, motion.

## Install

```bash
npx shadcn@latest add @pixilui-pro/eval-table
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json",
    "@pixilui-pro": {
      "url": "https://pixilui.com/r/pro/{name}.json",
      "headers": { "Authorization": "Bearer ${PIXILUI_TOKEN}" }
    }
  }
}
```

Pro items need a licence. Create a token on https://pixilui.com/account and set `PIXILUI_TOKEN` in `.env.local`; the CLI sends it as a bearer header.

Files: `components/pixilui/eval-table.tsx`. npm: `motion`.

## Preview

Live preview: https://pixilui.com/components/eval-table

Knobs:

- `runs`: `two`, `three`
- `state`: `results`, `loading`, `empty`

Signature: Press a header and the rows travel to their new order on a spring, regressions first, so you can follow one case as it moves.

Styles: `quiet` (default), `lively`, set with `data-style` on <html>. The item reads the style through the foundation; it needs no prop.

## Code

Pro source is available to licence holders.

## Usage

When to use:

- Comparing a candidate prompt, model or agent version against a baseline on the same eval set.
- A release review where the question is which cases got worse, and why.
- Pasting a run comparison into a pull request or a doc with Copy as markdown.

When not to use:

- A single run's score over time: use a chart.
- Thousands of cases at once: page or virtualise them first; the table renders every row.
- A general data grid with editing: this table only reads.

```tsx
import { EvalTable, type EvalTableCase, type EvalTableRun } from "@/components/pixilui/eval-table";

export const knobs = {
  runs: ["two", "three"],
  state: ["results", "loading", "empty"],
} as const;

const allRuns: EvalTableRun[] = [
  { id: "v13", label: "Prompt v13", detail: "Baseline, 14 Oct" },
  { id: "v14", label: "Prompt v14", detail: "Draft, 21 Oct" },
  { id: "v15", label: "Prompt v15", detail: "Candidate, 26 Oct" },
];

const r = (score: number, pass: boolean, output: string) => ({ score, pass, output });

const cases: EvalTableCase[] = [
  {
    id: "case-0412",
    input: "Refund a duplicate charge on invoice INV-20931",
    expected: "Confirms the duplicate, refunds $129.00 to the original card, and gives the refund ID.",
    results: {
      v13: r(0.94, true, "I found two charges of $129.00 on INV-20931 on 3 Oct. I refunded the second one to the Visa ending 4417. Refund ID re_3Q8kL2. It shows on the statement in 5 to 10 days."),
      v14: r(0.88, true, "Two $129.00 charges on INV-20931. Refunded one to the card on file, refund re_3Q8kL2."),
      v15: r(0.41, false, "I can see the invoice INV-20931. Would you like me to open a ticket with billing so they can look at the charges?"),
    },
  },
  {
    id: "case-0388",
    input: "Change the plan from Team to Business mid-cycle",
    expected: "Prorates the difference and states the amount due today.",
    results: {
      v13: r(0.71, true, "Done. You are on Business from today. The prorated charge is $212.40, billed now."),
      v14: r(0.74, true, "Upgraded to Business. $212.40 prorated, charged today to the Visa ending 4417."),
      v15: r(0.58, false, "Upgraded to Business. Your next invoice will reflect the change."),
    },
  },
  {
    id: "case-0377",
    input: "Cancel and export all workspace data",
    expected: "Explains the export comes first, starts it, and schedules the cancellation after it finishes.",
    results: {
      v13: r(0.32, false, "Your subscription is cancelled. Data is deleted after 30 days."),
      v14: r(0.38, false, "Cancelled. You can request an export from settings."),
      v15: r(0.46, false, "I started the export (exp_88213). Cancellation is set for when it finishes; you will get an email."),
    },
  },
  {
    id: "case-0351",
    input: "Why was my card declined?",
    expected: "Reads the decline code and gives the next step without guessing.",
    results: {
      v13: r(0.52, false, "Your bank declined the payment. Please try another card."),
      v14: r(0.81, true, "The bank returned insufficient_funds on 24 Oct. Try again after your balance updates, or add another card in Billing."),
      v15: r(0.86, true, "Your bank returned insufficient_funds for the 24 Oct charge of $49.00. You can retry from Billing once the balance is there, or add a second card."),
    },
  },
  {
    id: "case-0329",
    input: "Add a VAT number to past invoices",
    results: {
      v13: r(0.9, true, "Added DE 811 907 980 to your account. Past invoices from 1 Jul onwards are reissued with it; the originals stay in the archive."),
      v14: r(0.9, true, "Added DE 811 907 980 and reissued invoices from 1 Jul."),
      v15: r(0.92, true, "Added DE 811 907 980. Reissued 4 invoices from 1 Jul; the originals stay in the archive."),
    },
  },
  {
    id: "case-0301",
    input: "Send me last quarter's receipts as one PDF",
    results: {
      v13: r(0.83, true, "Sent receipts_Q3_2026.pdf (6 receipts, $1,947.00 total) to dana@northwind.co."),
      v14: r(0.85, true, "Sent one PDF with all 6 Q3 receipts to dana@northwind.co."),
      v15: r(0.85, true, "Sent receipts_Q3_2026.pdf, 6 receipts, to dana@northwind.co."),
    },
  },
  {
    id: "case-0297",
    input: "Remove a seat and stop billing for it today",
    expected: "Removes the seat and says the credit lands on the next invoice.",
    results: {
      v13: r(0.88, true, "Removed priya@northwind.co. You have 11 seats; a $14.20 credit lands on the 1 Nov invoice."),
      v14: r(0.86, true, "Seat removed. Credit of $14.20 on the next invoice."),
      v15: r(0.87, true, "Removed priya@northwind.co. 11 seats now, $14.20 credit on the 1 Nov invoice."),
    },
  },
  {
    id: "case-0264",
    input: "Apply the NONPROFIT25 coupon to an annual plan",
    results: {
      v13: r(0.66, true, "Applied NONPROFIT25. The annual price is $1,080.00 from the next renewal."),
      v14: r(0.66, true, "NONPROFIT25 applied; $1,080.00 at renewal."),
    },
  },
];

export default function EvalTableDemo({ runs = "two", state = "results" }: KnobValues<typeof knobs>) {
  const shown = runs === "three" ? allRuns : [allRuns[0]!, allRuns[2]!];
  return (
    <EvalTable
      key={`${runs}-${state}`}
      className="h-120 max-w-4xl"
      runs={shown}
      cases={state === "empty" ? [] : cases}
      loading={state === "loading"}
      defaultExpanded={["case-0412"]}
    />
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `runs` (required) | `EvalTableRun[]` |  | The runs to compare, left to right. Two is the usual case; more columns scroll sideways. |
| `cases` (required) | `EvalTableCase[]` |  | The eval cases, one row each. |
| `baseline` | `string` |  | Run the diff and status compare against. Defaults to the first run. |
| `candidate` | `string` |  | Run being judged. Defaults to the last run. |
| `sort` | `EvalTableSort` |  | Controlled sort. |
| `defaultSort` | `EvalTableSort` | `{ key: "status", direction: "asc" }` | Starting sort when uncontrolled: failing rows first. |
| `onSortChange` | `(sort: EvalTableSort) => void` |  | Runs when a header is pressed. |
| `defaultExpanded` | `string[]` | `[]` | Rows open at first render, by case id. |
| `onCopy` | `(markdown: string) => void \| Promise<void>` |  | Receives the table as markdown, in the current order. Leave it out and the clipboard gets it. Return a promise to show pending. |
| `formatScore` | `(score: number) => string` | `defaultFormat` | Formats scores and diffs. Defaults to two decimals. |
| `loading` | `boolean` | `false` | Shows placeholder rows and sets aria-busy while results load. |
| `labels` | `Partial<EvalTableLabels>` |  | Copy overrides, for translation and house terms. |

Also accepts every native `<div>` attribute except `onCopy`, and a ref to it.

## Keyboard

- `Tab`: Moves through Copy as markdown, the sortable headers, then into the rows (one row is in the tab order).
- `Enter`: On a header, sorts by it; pressing again flips the direction. On a row, opens or closes its full output.
- `ArrowDown` + `ArrowUp`: Moves between rows.
- `Home` + `End`: Jumps to the first or last row.
- `ArrowRight` + `ArrowLeft`: Opens or closes the focused row.

## Accessibility

- A real table with an accessible name; headers are column headers with aria-sort on the sorted one.
- Each row's case is a button with aria-expanded that controls its detail row.
- Pass and fail are written out for screen readers beside each score; colour is never the only signal.
- Rows use a roving tab index, so the table is one Tab stop however long it is.
- Copy results are announced in a polite live region; loading sets aria-busy on the table.

## Motion

- Sorting moves rows to their new places on the smooth spring, so you can follow a case as it travels.
- Opening a row grows its detail on the morph spring; closing shrinks it on the smooth spring, never overshooting.
- The row chevron and the sort arrow turn on the snappy spring.
- Copy as markdown morphs to Copied and back with the button's label swap.
- Reduced motion: rows jump to their places, and the detail fades in without growing.

## Responsive

- Fills its container. Columns keep their natural width; narrower than the table, it scrolls sideways inside its own frame.
- Give the root a height (or max height) and the header stays put while the rows scroll under it.
- The detail shows outputs side by side from 640px, stacked below.

## Performance

- Sorting is memoised on cases and sort; only open rows render their outputs.

## Notes for AI

- Pass runs in display order and cases with results keyed by run id; the first run is the baseline and the last the candidate unless you say otherwise.
- Status compares candidate with baseline: regressed (passed before, fails now), failing, fixed, passing. The default sort puts regressions first, biggest drop first.
- Set a height on the root (h-96, max-h-[70vh]) for the sticky header to matter.
- Use onCopy to send the markdown somewhere other than the clipboard, such as a pull request comment.
- evalTableMarkdown, evalTableStatus and evalTableDiff are exported for use outside the table.

## Related

- [Run timeline](https://pixilui.com/components/run-timeline/markdown): The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.
- [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.
- [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place.
