Structured output A comparison answered as a grid. Real table markup so a screen reader can associate a value with its row and column, a source chip on any cell that carries a fact, a scrolling container past a few columns, and a control to ask for a different shape without re-asking. npx shadcn@latest add table button Tokens this needs: --card, --card-foreground, --muted, --muted-foreground, --border, --accent ──────────────────────────────────────────────────────────────────────── // AnswerTable.tsx ──────────────────────────────────────────────────────────────────────── import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; /** * A comparison answered as a grid. * * Real table markup, because a grid drawn with spacing is a wall of numbers * to a screen reader; header cells are headers. Each cell can carry its own * source, because a number in a cell is believed in a way the same number in * a sentence is not, and one chip on the whole answer does not say which * cells it covers. The table scrolls inside its own box past a few columns * rather than pushing the page sideways. */ export type Source = { id: number; label: string; href?: string }; export type Cell = { value: string; source?: Source }; export type Shape = "table" | "list"; type Props = { columns: string[]; /** One entry per column, in column order. */ rows: Cell[][]; onSource: (source: Source) => void; /** The model routed this answer to a table. The viewer may disagree without * asking the question again. */ onReshape: (shape: Shape) => void; onExport: (csv: string) => void; }; /** RFC 4180: quote a field that holds a comma, a quote or a newline. */ const field = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s); export const toCsv = (columns: string[], rows: Cell[][]) => [columns, ...rows.map((r) => r.map((c) => c.value))].map((r) => r.map(field).join(",")).join("\n"); export function AnswerTable({ columns, rows, onSource, onReshape, onExport }: Props) { return (
{columns.map((c) => ( {c} ))} {rows.map((cells, i) => ( {cells.map((cell, j) => ( {cell.value} {cell.source && ( )} ))} ))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { AnswerTable, type Cell, type Shape, type Source } from "./AnswerTable"; /** * Three vendors across price and SLA, the kind of question that arrives as * five paragraphs. Two of the prices are sourced to a document in the * workspace; the third came from the vendor's public page and is not. * "as a list" reshapes the same cells without re-asking. */ const QUOTE: Source = { id: 1, label: "Acme quote, 4 Sep", href: "#acme-quote" }; const PROPOSAL: Source = { id: 2, label: "Borden proposal, p. 3", href: "#borden-proposal" }; const COLUMNS = ["Vendor", "Price", "SLA"]; const ROWS: Cell[][] = [ [{ value: "Acme" }, { value: "£4,200", source: QUOTE }, { value: "99.9%" }], [{ value: "Borden" }, { value: "£3,850", source: PROPOSAL }, { value: "99.5%" }], [{ value: "Curtis" }, { value: "£5,010" }, { value: "99.95%" }], ]; export default function Demo() { const [shape, setShape] = useState("table"); return (
{shape === "table" ? ( { if (s.href) location.hash = s.href; }} onReshape={setShape} onExport={(csv) => navigator.clipboard.writeText(csv)} /> ) : (
    {ROWS.map(([vendor, price, sla]) => (
  1. {vendor.value}: {price.value} a month, {sla.value} uptime
  2. ))}
)}
); }