Skip to content

AI assistance

AI everywhere without noise: generate buttons, streaming summaries, approval cards and the one Copilot.

The problem#

AI can help with almost every screen in the suite. Bolted on everywhere, it turns into noise: a chat bubble per page, gradient buttons, changes nobody approved.

Cedarline's team should meet AI where the work already is: a draft beside the field they're filling, a summary on the record they opened, a proposal waiting for their decision. It should look like the rest of the product, say where its answers came from, and never change a record behind anyone's back.

The solution#

Seven rules, from the suite brief, that decide every AI surface in Oration.

AI everywhere, without noise
RuleIn practice
Draft where the text goesAny free-text field that describes something (an agent, a tool, a ticket summary, a procedure step, a scorecard criterion, a campaign message) gets an AI generate button inside or beside it.
Long prompts get the editorAgent prompts and skill instructions use the Prompt editor, whose AI actions arrive as diffs to accept or reject.
Summaries cite their sourcesLists and records that summarize a conversation, a ticket or a supplier stream the summary with Streaming text and numbered source cards.
People approve changesAn agent that wants to change something proposes it in an Approval card. Approve, Edit and Skip; Undo afterwards.
One chat surfaceThe Copilot, on ⌘J from anywhere, knows the current app and record. No page builds its own chat panel, except the knowledge base query and the Contact Center assist panel.
Show uncertaintyRecommendations carry a confidence meter in ink with a word: high, medium or low.
Never act silentlyEvery AI change is visible where it happened, logged with the agent as the actor, and reversible.

The Quiet Indigo Rule

Indigo is spent on the view's one filled primary action, on selection, on focus, and on live state that carries a text label (running, active, upcoming), plus the viewer's own markers: the unread dot, an @mention of you, the unsaved-changes dot. It never fills data, tints an icon tile, colors a heading or decorates.

The One Filled Button Rule

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

The Machine Mono Rule

Geist Mono is only for strings a machine produced or will parse: record and run IDs, API keys, tokens, codes, DNS records, template variables, E.164 phone numbers, timecodes. Figures, labels, keyboard keys and headings stay in Geist Sans.

Choosing the AI surface#

Start from what the person is doing, not from the model's capability.

Which AI component to use
When people areUseNot
Filling a short descriptive fieldAIGenerateButton beside the label, or AIFieldAction in the field's cornerA dialog that asks for a prompt first.
Writing a long prompt or instructionsPromptEditor with onAIAction: slash menu, bubble actions on a selection, inline diffsA textarea with a Generate button that overwrites everything.
Reading a record, ticket or callStreamingText with citations in a summary block at the topAn unsourced paragraph, or a chat answer.
Deciding on something an agent wants to doApprovalCard with the change as fields, Approve, Edit and SkipA toast after the agent already did it.
Weighing a suggested next stepRecommendationCard with confidence, impact and sourcesA suggestion with no reason or certainty.
Asking an open questionThe Copilot, ⌘J, with tool chips and thinking in the transcriptA chat panel on this page.
Editing selected textSelection actions: rewrite, shorten, translate, askA separate AI editor.

Drafting in a field#

The sparkles button is ghost, small and beside the field's label. It drafts, shimmers while working, fills the field and toasts Draft added with Undo.

Draft a summary or a description

Draft fills the ticket summary; the corner button fills the tool description. Undo in the toast restores what was there.

import { AIFieldAction, AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Input } from "@oration/canon/components/input";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function DraftInField() {    const summaryId = React.useId();    const toolId = React.useId();    const [summary, setSummary] = React.useState("");    const [tool, setTool] = React.useState("");    const previousSummary = React.useRef("");    const previousTool = React.useRef("");    return (        <div className="flex w-full max-w-md flex-col gap-5 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex flex-col gap-2">                <div className="flex items-center justify-between gap-3">                    <label                        htmlFor={summaryId}                        className="text-sm font-medium text-foreground"                    >                        Ticket summary                    </label>                    <AIGenerateButton                        label="Draft"                        onGenerate={() =>                            "Halcyon Packaging says INV-20931 was paid $412.50 short. The difference is the 2% early-pay discount from their 2/10 net 30 terms, applied on September 25. They need remittance advice that shows the discount as its own line."                        }                        onResult={(text) => {                            previousSummary.current = summary;                            setSummary(text);                        }}                        onUndo={() => setSummary(previousSummary.current)}                    />                </div>                <Textarea                    id={summaryId}                    value={summary}                    onChange={(event) => setSummary(event.target.value)}                    placeholder="What the supplier needs, in a sentence or two"                    className="min-h-24"                />            </div>            <div className="flex flex-col gap-2">                <label                    htmlFor={toolId}                    className="text-sm font-medium text-foreground"                >                    Tool description                </label>                <div className="relative">                    <Input                        id={toolId}                        value={tool}                        onChange={(event) => setTool(event.target.value)}                        placeholder="When should Nora call get_payment_status?"                        className="pr-9"                    />                    <AIFieldAction                        label="Draft a description"                        onGenerate={() =>                            "Look up when an invoice will be paid. Use it when a supplier asks about a payment date."                        }                        onResult={(text) => {                            previousTool.current = tool;                            setTool(text);                        }}                        onUndo={() => setTool(previousTool.current)}                    />                </div>            </div>        </div>    );}
Ticket summary
Do. A ghost Draft button with the sparkles icon, in Slate Meta until hover. The field stays the focus.
Ticket summaryWrite with AI magic
Don't. A gradient pill that shouts about AI. It's the loudest thing on the form and breaks the gradient refusal.

The sparkles icon#

It is the only visual marker of AI. It stays in Slate Meta at the size of the icons around it, never indigo, and only pulses softly while a draft is generating.

Two drafting buttons, one look

AIGenerateButton shows Generating… in a shimmer, while AIFieldAction swaps its icon for a pixel grid and keeps its tooltip as the only label. Prefer the labelled button where there's room beside the field.

Long prompts#

Prompts for Nora and the other agents are documents. The editor tracks changes against the published version and proposes AI edits as diffs, so nothing is replaced without a look.

AI actions in the prompt editor

Loading editor
40 words~55 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Select the sentence under How you sound and choose Rewrite, or type / on the empty last line and choose Add guardrails. Every change arrives as a diff to accept or reject.

import { PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";import * as React from "react";export function LongPrompt() {    const [value, setValue] = React.useState(        [            "# How you sound",            "Be nice to callers and help them with whatever they need about payments.",            "",            "# What you do",            "1. Greet {{supplier_name}} and ask for the invoice number.",            "2. Look up the payment and tell them when it goes out.",            "",        ].join("\n"),    );    return (        <div className="flex w-full flex-col gap-2">            <PromptEditor                value={value}                onChange={setValue}                variables={[                    {                        name: "supplier_name",                        description:                            "The supplier's name from the vendor record.",                        example: "Northwind Freight",                    },                ]}                minHeight={200}                aria-label="Nora prompt"                onAIAction={async (request) => {                    await new Promise((resolve) =>                        window.setTimeout(resolve, 500),                    );                    if (request.kind === "guardrails")                        return [                            "# Guardrails",                            "- Never read out full bank account or routing numbers.",                            "- If the caller disputes an amount, transfer them to Priya Raman's team.",                        ].join("\n");                    if (request.selection?.includes("Be nice"))                        return "Warm, unhurried and exact. Read amounts and dates back slowly, and never guess.";                    return request.selection ?? request.document;                }}            />            <p className="text-13 text-pretty text-muted-foreground">                Select the sentence under How you sound and choose Rewrite, or                type / on the empty last line and choose Add guardrails. Every                change arrives as a diff to accept or reject.            </p>        </div>    );}

Summaries with sources#

A summary streams in with a soft caret, cites each claim with a numbered marker, and lists the sources underneath. Hovering a marker highlights its card.

A supplier summary

Hover a citation to preview its source. Summarize again replays the stream; the follow-ups open in the Copilot.

Summary

Halcyon Packaging, last 30 days

Sources3

  1. 1Call on Sep 24Call with Halcyon AR“We sent the new W-9 to the old portal address. Can you check if it arrived?”
  2. 2InvoiceInvoice INV-20431$4,912.50, due Oct 5. On hold: W-9 on file expired Aug 31.
  3. 3Knowledge baseSupplier onboarding policyPayments are held until a current W-9 is on file. Requests go out from the supplier portal.
import type { Source } from "@oration/canon/components/ai/source-cards";import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { RotateCcwIcon, SparklesIcon } from "lucide-react";import * as React from "react";export function RecordSummary() {    const [run, setRun] = React.useState(0);    const sources: Source[] = [        {            id: "call-0924",            kind: "transcript",            title: "Call with Halcyon AR",            snippet:                "“We sent the new W-9 to the old portal address. Can you check if it arrived?”",            meta: "Call on Sep 24",        },        {            id: "inv-20431",            kind: "record",            title: "Invoice INV-20431",            snippet:                "$4,912.50, due Oct 5. On hold: W-9 on file expired Aug 31.",            meta: "Invoice",        },        {            id: "policy-w9",            kind: "doc",            title: "Supplier onboarding policy",            snippet:                "Payments are held until a current W-9 is on file. Requests go out from the supplier portal.",            meta: "Knowledge base",        },    ];    return (        <section            aria-labelledby="record-summary-title"            className="w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"        >            <header className="mb-2 flex items-center gap-2">                <SparklesIcon                    aria-hidden="true"                    className="size-4 text-muted-foreground"                />                <h3                    id="record-summary-title"                    className="text-13 font-medium text-foreground"                >                    Summary                </h3>                <span className="text-xs text-muted-foreground">                    Halcyon Packaging, last 30 days                </span>                <Tooltip>                    <TooltipTrigger                        render={                            <Button                                type="button"                                variant="ghost"                                size="icon-xs"                                aria-label="Summarize again"                                className="ml-auto text-muted-foreground"                                onClick={() => setRun((current) => current + 1)}                            />                        }                    >                        <RotateCcwIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>Summarize again</TooltipContent>                </Tooltip>            </header>            <StreamingText                key={run}                text="Halcyon's invoice **INV-20431** for $4,912.50 is on hold because their W-9 expired on August 31 [2]. Their AR team says they sent a new one to the old portal address on September 24 [1]. Payments stay held until a current W-9 is on file [3].\n\n**Next step:** send a W-9 request from the supplier portal so the form lands in the right place."                speed={run === 0 ? 260 : 200}                citations={sources}                followUps={[                    "Draft the W-9 request",                    "Show other invoices on hold",                ]}                onFollowUp={(text) =>                    toast.add({ title: "Asked Copilot", description: text })                }                className="text-sm leading-relaxed text-pretty"            />        </section>    );}
  • Put the summary at the top of the record or list it summarizes, under a 13px Summary heading with the sparkles icon in Slate Meta and the scope beside it: Halcyon Packaging, last 30 days.
  • Every factual claim gets a citation. If the model can't cite it, it doesn't say it.
  • End with one next step in bold, and at most two follow-up chips.
  • Under reduced motion the whole text renders at once.

Proposals and approval#

Agents propose; people decide. The approval card shows what would change as before and after values, with Approve, Edit and Skip. Once resolved, it collapses to a receipt with Undo.

A stack of proposals

Only the expanded proposal shows its filled button. Approve or skip it and the next one opens; Undo on the receipt brings it back.

  • Update Halcyon's remit-to email

    Wen Zhou's call at 10:42: Halcyon's AR team asked for remittance advice at a new address.

    Remit-to email
    Before: ap@halcyonpack.comAfter: remittance@halcyonpack.com
    Updates 1 supplier record
import { ApprovalCard, type ApprovalStatus } from "@oration/canon/components/ai/approval-card";import { toast } from "@oration/canon/components/toast";import { BanknoteIcon, ChevronRightIcon, FileWarningIcon, MailIcon } from "lucide-react";import * as React from "react";export function ProposalStack() {    const proposals = [        {            id: "remit-email",            icon: <MailIcon aria-hidden="true" />,            agent: "Nora",            title: "Update Halcyon's remit-to email",            description:                "Wen Zhou's call at 10:42: Halcyon's AR team asked for remittance advice at a new address.",            fields: [                {                    label: "Remit-to email",                    before: "ap@halcyonpack.com",                    after: "remittance@halcyonpack.com",                },            ],            footnote: "Updates 1 supplier record",            approveLabel: "Approve",            done: "Halcyon's remit-to email updated",        },        {            id: "hold",            icon: <FileWarningIcon aria-hidden="true" />,            agent: "Matching agent",            title: "Hold INV-20447 until Bayline sends a W-9",            description:                "Bayline Logistics' W-9 expired on September 1. Paying without one breaks the onboarding policy.",            fields: [{ label: "Status", before: "Matched", after: "On hold" }],            footnote: "Leaves Thursday's payment run",            approveLabel: "Hold invoice",            done: "INV-20447 is on hold",        },        {            id: "early-pay",            icon: <BanknoteIcon aria-hidden="true" />,            agent: "Cash agent",            title: "Pay Northwind Freight early for 2%",            description:                "INV-20417 has 2/10 net 30 terms. Paying Thursday instead of October 2 saves $364.80.",            fields: [                {                    label: "Pay date",                    before: "Oct 2",                    after: "Thursday, Oct 1",                },            ],            footnote: "Moves 1 invoice to Thursday's run",            approveLabel: "Approve",            done: "INV-20417 pays on Thursday",        },    ];    const [status, setStatus] = React.useState<Record<string, ApprovalStatus>>(        {},    );    const [activeId, setActiveId] = React.useState<string | null>(        proposals[0]?.id ?? null,    );    const resolve = (id: string, next: ApprovalStatus) => {        const updated = { ...status, [id]: next };        setStatus(updated);        const pending = proposals.find(            (proposal) => (updated[proposal.id] ?? "pending") === "pending",        );        setActiveId(pending?.id ?? null);    };    return (        <ul            aria-label="Needs your call"            className="flex w-full max-w-lg flex-col gap-2 text-left"        >            {proposals.map((proposal) => {                const current = status[proposal.id] ?? "pending";                if (current === "pending" && proposal.id !== activeId) {                    return (                        <li key={proposal.id}>                            <button                                type="button"                                onClick={() => setActiveId(proposal.id)}                                className="flex w-full items-center gap-2.5 rounded-xl bg-card px-3 py-2.5 text-left shadow-border transition-shadow duration-150 outline-none hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40"                            >                                <span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4">                                    {proposal.icon}                                </span>                                <span className="flex min-w-0 flex-1 flex-col">                                    <span className="truncate text-13 font-medium text-foreground">                                        {proposal.title}                                    </span>                                    <span className="text-xs text-muted-foreground">                                        Proposed by {proposal.agent}                                    </span>                                </span>                                <ChevronRightIcon                                    aria-hidden="true"                                    className="size-4 text-muted-foreground"                                />                            </button>                        </li>                    );                }                return (                    <li key={proposal.id}>                        <ApprovalCard                            icon={proposal.icon}                            title={proposal.title}                            description={proposal.description}                            fields={proposal.fields}                            footnote={proposal.footnote}                            approveLabel={proposal.approveLabel}                            status={current}                            emphasis="primary"                            onApprove={() => {                                resolve(proposal.id, "approved");                                toast.add({                                    type: "success",                                    title: proposal.done,                                    description: `${proposal.agent} logged the change on the record.`,                                });                            }}                            onSkip={() => resolve(proposal.id, "skipped")}                            onEdit={() =>                                toast.add({                                    title: "Editing the proposal",                                    description:                                        "Change the values, then approve.",                                })                            }                            onUndo={() => {                                setStatus((all) => ({                                    ...all,                                    [proposal.id]: "pending",                                }));                                setActiveId(proposal.id);                            }}                        />                    </li>                );            })}        </ul>    );}

Update Halcyon's remit-to email

Remit-to email
Before: ap@halcyonpack.comAfter: remittance@halcyonpack.com
Do. Propose the change with its before and after, and wait for a decision.
Nora updated Halcyon's remit-to email to remittance@halcyonpack.com.
Don't. Make the change and report it afterwards. Nobody chose it, and there's nothing to undo from.

Agents that act on their own

Some agent runs act without a card because a workspace policy allows it, such as Nora logging a call note. Those changes still show the agent as the actor in the record's activity and in the agent's run log, and they can be reversed from there.

The Copilot#

One chat surface for the whole suite: a right-side panel on ⌘J, or the sparkles button in the sidebar header, or any Ask Copilot affordance. It knows the current app and record.

A Copilot transcript

Thinking collapses to Thought for 4s. Each tool call is a chip whose input and output open in Geist Mono. Actions arrive as approval cards inside the answer.

Copilot

on Halcyon Packaging⌘J
Why is INV-20431 on hold? Chase it if you can.
Copilot
Thought for 4 seconds

It's held because Halcyon's W-9 expired on August 31, and nothing new has reached the portal. I can email their AR team a W-9 request.

Email Halcyon a W-9 request

To ar@halcyonpack.com from ap@cedarline.com, with the portal upload link.

Sends 1 email
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { AssistantMessage, UserMessage } from "@oration/canon/components/ai/message";import { Thinking } from "@oration/canon/components/ai/thinking";import { ToolChip } from "@oration/canon/components/ai/tool-chip";import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { MailIcon, SparklesIcon } from "lucide-react";export function CopilotTranscript() {    return (        <section            aria-label="Copilot"            className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"        >            <header className="flex h-11 items-center gap-2 border-b border-border px-4">                <SparklesIcon                    aria-hidden="true"                    className="size-4 text-muted-foreground"                />                <h3 className="text-sm font-semibold text-foreground">                    Copilot                </h3>                <span className="text-xs text-muted-foreground">                    on Halcyon Packaging                </span>                <KbdGroup className="ml-auto">                    <Kbd>⌘</Kbd>                    <Kbd>J</Kbd>                </KbdGroup>            </header>            <div className="flex flex-col gap-5 p-4">                <UserMessage>                    Why is INV-20431 on hold? Chase it if you can.                </UserMessage>                <AssistantMessage name="Copilot" actionsVisibility="hover">                    <div className="flex flex-col gap-3">                        <Thinking                            status="done"                            elapsed={4}                            steps={[                                {                                    label: "Read the invoice and its hold reason",                                },                                {                                    label: "Checked Halcyon's documents",                                    detail: "The W-9 on file expired on August 31.",                                },                                {                                    label: "Looked for a newer W-9 in the portal",                                },                            ]}                        />                        <div className="flex flex-col gap-1.5">                            <ToolChip                                name="get_invoice"                                label="Looked up INV-20431"                                status="done"                                duration={240}                                input={{ invoice_id: "INV-20431" }}                                output={{                                    status: "on_hold",                                    reason: "w9_expired",                                    amount: 4912.5,                                }}                            />                            <ToolChip                                name="list_supplier_documents"                                label="Checked Halcyon's documents"                                status="done"                                duration={610}                                input={{ supplier_id: "SUP-1184", type: "w9" }}                                output={{                                    latest: "2025-08-31",                                    portal_uploads: 0,                                }}                            />                        </div>                        <p className="text-sm leading-relaxed text-pretty">                            It's held because Halcyon's W-9 expired on August                            31, and nothing new has reached the portal. I can                            email their AR team a W-9 request.                        </p>                        <ApprovalCard                            icon={<MailIcon aria-hidden="true" />}                            title="Email Halcyon a W-9 request"                            description="To ar@halcyonpack.com from ap@cedarline.com, with the portal upload link."                            footnote="Sends 1 email"                            approveLabel="Send email"                            onApprove={() =>                                toast.add({                                    type: "success",                                    title: "W-9 request sent to Halcyon",                                    description:                                        "Copilot will tell you when the form arrives.",                                })                            }                            onSkip={() => undefined}                            onUndo={() =>                                toast.add({                                    title: "Email recalled",                                    description:                                        "It hadn't left the outbox yet.",                                })                            }                        />                    </div>                </AssistantMessage>            </div>        </section>    );}
Keyboard interactions
KeysAction
⌘JOpens or closes the Copilot from anywhere in the suite.
EscCloses the Copilot and returns focus to the page.
Halcyon Packaging
Do. Send questions to the Copilot from where they come up, with the record as context.
Hi there, ask me anything about invoicesType a message
Don't. Float a chat bubble on a page. It's a second assistant with its own memory, and it covers the work.

Confidence#

A recommendation says how sure it is in words and in an ink meter, never in indigo. Low confidence asks a person to check rather than offering to act.

High and low confidence

Pay Northwind Freight early

High confidence
86%
INV-20417 has 2/10 net 30 terms and Thursday's run has room. Northwind has accepted early payment on its last six invoices.
Saves$364.80

Halcyon may be changing banks

Low confidence
48%
One call mentioned “our new account”, but no bank change request has arrived. Confirm with Halcyon before changing anything.
import { RecommendationCard } from "@oration/canon/components/ai/recommendation-card";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { BanknoteIcon, FileWarningIcon } from "lucide-react";export function Confidence() {    return (        <div className="grid w-full gap-3 text-left md:grid-cols-2">            <RecommendationCard                icon={<BanknoteIcon aria-hidden="true" />}                title="Pay Northwind Freight early"                confidence={0.86}                rationale="INV-20417 has 2/10 net 30 terms and Thursday's run has room. Northwind has accepted early payment on its last six invoices."                impact={{ label: "Saves", value: "$364.80" }}                actions={                    <>                        <Button                            size="sm"                            onClick={() =>                                toast.add({                                    type: "success",                                    title: "Added to Thursday's run",                                    description:                                        "INV-20417 now pays on October 1.",                                    actionProps: {                                        children: "Undo",                                        onClick: () =>                                            toast.add({                                                title: "INV-20417 pays on October 2 again",                                            }),                                    },                                })                            }                        >                            Add to Thursday's run                        </Button>                        <Button                            size="sm"                            variant="ghost"                            onClick={() =>                                toast.add({                                    title: "Dismissed for this invoice",                                })                            }                        >                            Not now                        </Button>                    </>                }            />            <RecommendationCard                icon={<FileWarningIcon aria-hidden="true" />}                title="Halcyon may be changing banks"                confidence={0.48}                rationale="One call mentioned “our new account”, but no bank change request has arrived. Confirm with Halcyon before changing anything."                actions={                    <Button                        size="sm"                        variant="outline"                        onClick={() =>                            toast.add({                                title: "Task created for Priya Raman",                                description:                                    "Confirm Halcyon's bank details by phone.",                            })                        }                    >                        Ask Priya to confirm                    </Button>                }            />        </div>    );}
Confidence bands
ValueLabelWhat the card offers
0.80 and upHigh confidenceThe action itself, as the filled button when the card is the view's main decision.
0.55 to 0.79Medium confidenceThe action as an outline button, with the reasoning above it.
Below 0.55Low confidenceA way to check: a task, a question or a link to the evidence. Not the change.

Undo, and never acting silently#

Whatever the AI did, the person can see it where it happened and take it back.

What people see after each kind of AI change
The AIPeople seeTo reverse it
Drafted text into a fieldThe field filled, and a Draft added toast.Undo in the toast, or edit the text.
Proposed a change that was approvedThe card's receipt, Approved, and the change on the record with the agent as the actor.Undo on the receipt, or revert from the record's activity.
Rewrote part of a promptAn inline diff to accept or reject, then a changes count on the save bar.Reject the diff, or Discard on the save bar.
Acted under a workspace policyAn entry in the record's activity and the agent's run log.Revert from the activity entry.
FailedA toast that says nothing changed: Couldn't generate a draft. Nothing changed.Try again from the toast.

Accessibility#

AI surfaces are built from the same primitives as everything else, so the same rules apply, plus a few about time and change.

  • Generate buttons set aria-busy while drafting and announce Generating a draft through a polite status region.
  • Streaming text is aria-busy until it finishes. Screen readers read the finished text, not each token.
  • Citations are buttons or links named Source 2: Invoice INV-20431, and preview on focus as well as hover.
  • The approval card moves focus to Undo when it resolves, so a keyboard user can reverse the decision straight away, and announces Approved or Skipped.
  • Tool chips read their status after the name (get_invoice, Done) and expand with the keyboard.
  • Confidence is a meter with a text label and a number, so it reads without color.

Components#

The AI primitives live in @oration/canon/components/ai/.