Diff
Inline word diffs and field-change tables for proposed edits.
Proposed prompt change
Ask added: for the removed: calleradded: invoice removed: foradded: number, added: or its last four digits with the removed: invoiceadded: supplier removed: number.added: name. If the payment is removed: not yetadded: scheduled, removed: sent,added: read removed: telladded: the removed: themadded: run removed: itadded: date removed: isadded: from removed: processingadded: {{payment_run_date}} and added: say when it will removed: arriveadded: be removed: soon.added: sent.
import { InlineDiff } from "@oration/canon/components/ai/diff";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const before = "Ask the caller for the invoice number. If the payment is not yet sent, tell them it is processing and will arrive soon."; const after = "Ask for the invoice number, or its last four digits with the supplier name. If the payment is scheduled, read the run date from {{payment_run_date}} and say when it will be sent."; const [applied, setApplied] = React.useState(false); return ( <section className="flex w-full max-w-xl flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <h3 className="text-13 font-semibold text-foreground"> Proposed prompt change </h3> <p className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-13 leading-relaxed text-foreground"> {applied ? after : <InlineDiff before={before} after={after} />} </p> <div className="flex justify-end gap-2"> {applied ? ( <Button type="button" variant="ghost" size="sm" onClick={() => setApplied(false)} > Undo </Button> ) : ( <> <Button type="button" variant="ghost" size="sm" onClick={() => toast.add({ title: "Change discarded", description: "Version 13 is unchanged.", }) } > Discard </Button> <Button type="button" size="sm" onClick={() => { setApplied(true); toast.add({ type: "success", title: "Edit applied to the draft", }); }} > Apply edit </Button> </> )} </div> </section> );}Usage#
Diff shows a proposed change before anyone accepts it. InlineDiff compares two versions of a text word by word: removed words are struck through on a red tint, added words sit on a green tint, and the rest reads normally. DiffTable lists field changes with the old value struck through beside the new one, as a table or stacked for narrow panels. diffWords is the algorithm underneath. The thing people get wrong is diffing whole paragraphs that were rewritten: when almost every word changed, show the new text and offer the old one, because a diff of everything is unreadable.
When to use
- For an AI rewrite of a prompt, a macro or a description, before someone applies it.
- When comparing two versions of a prompt, such as a draft against the live version.
- In an approval card, as a stacked
DiffTable, for the fields an action will change. - On a page, as a
DiffTable, for a batch of record changes such as a supplier's new bank and remit-to details. - With
diffWordswhen you need counts or your own rendering, such as 11 words added in a version list.
When not to use
- For a single changed select value on a record. Show the old tag struck through, an arrow and the new tag. Use Tag
- For a change that needs Approve, Edit and Skip. Use Approval card
- For an AI rewrite inside the long-form editor, which shows its own inline diff to accept or reject. Use Prompt editor
- For the history of changes over time. Use Timeline
- For code or JSON, where a line diff reads better than a word diff. Canon has no line diff yet.
Destructive is a tint
The Label-Beside-Color Rule
<del> and <ins> are rarely announced.The Tint Well Rule
Anatomy#
Payment runs go out removed: weekly.added: Tuesdays and Fridays.
- Payment method
- Before: ACHAfter: Wire
- Unchanged text. Renders as is, in the surrounding type.
- Removed. A
<del>with a 10% red tint, red text, a 50% red strikethrough and 3px corners that wrap across lines. - Added. An
<ins>with a 15% green tint, ink text and no underline. - Field. The field name in 12px Slate Meta (stacked) or a 13px row header (table).
- Before. The old value in Slate Meta, struck through. An empty value shows a Faint Slate dash, announced as Empty.
- Arrow. 14px, Faint Slate, between the values in the stacked layout.
- After. The new value in ink.
Examples#
Inline diffs
A replaced phrase, an added clause and a removed one. Each sits in a tint well so the marks read against a neutral ground.
- Payment runs go out
removed: weekly.added: Tuesdays and Fridays. - Ask for the invoice
removed: number.added: number or its last four digits. - Tell the caller the payment is
removed: processing and will arrive soon.added: scheduled.
import { InlineDiff } from "@oration/canon/components/ai/diff";export function ShortEdits() { const edits = [ { before: "Payment runs go out weekly.", after: "Payment runs go out Tuesdays and Fridays.", }, { before: "Ask for the invoice number.", after: "Ask for the invoice number or its last four digits.", }, { before: "Tell the caller the payment is processing and will arrive soon.", after: "Tell the caller the payment is scheduled.", }, ]; return ( <ul className="flex w-full max-w-lg flex-col gap-2"> {edits.map((edit) => ( <li key={edit.before} className="rounded-[10px] bg-muted/70 px-3 py-2 text-13 leading-relaxed" > <InlineDiff before={edit.before} after={edit.after} /> </li> ))} </ul> );}Live comparison
InlineDiff recomputes as either side changes. Edit the texts to see how word runs merge.
Thanks for calling removed: Cedarline.added: Cedarline added: supplier support. I can help with removed: paymentsadded: payments, added: remittances and removed: remittances.added: W-9s.
import { InlineDiff } from "@oration/canon/components/ai/diff";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function TryIt() { const beforeId = React.useId(); const afterId = React.useId(); const [before, setBefore] = React.useState( "Thanks for calling Cedarline. I can help with payments and remittances.", ); const [after, setAfter] = React.useState( "Thanks for calling Cedarline supplier support. I can help with payments, remittances and W-9s.", ); return ( <div className="grid w-full gap-4 sm:grid-cols-2"> <div className="flex flex-col gap-1.5"> <Label htmlFor={beforeId}>Before</Label> <Textarea id={beforeId} value={before} onChange={(e) => setBefore(e.target.value)} /> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={afterId}>After</Label> <Textarea id={afterId} value={after} onChange={(e) => setAfter(e.target.value)} /> </div> <p className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-13 leading-relaxed text-foreground sm:col-span-2"> <InlineDiff before={before} after={after} /> </p> </div> );}Field changes as a table
Three columns for a page: field, before struck through, after. A new field shows a dash before it.
Changes to Northwind Freight
| Field | Before | After |
|---|---|---|
| Remit-to email | Before: ar@northwindfreight.example | remittances@northwindfreight.example |
| Payment method | Before: ACH | Wire |
| Bank account | Before: Account ending 4821 | Account ending 0937 |
| Payment terms | Before: Net 30 | 2/10 net 30 |
| AP contact | Empty | Jordan Lee |
import { DiffTable } from "@oration/canon/components/ai/diff";export function Table() { return ( <div className="w-full max-w-2xl rounded-xl bg-card p-4 shadow-border"> <h3 className="mb-2 text-13 font-medium text-foreground"> Changes to Northwind Freight </h3> <DiffTable caption="Changes to Northwind Freight" rows={[ { field: "Remit-to email", before: "ar@northwindfreight.example", after: "remittances@northwindfreight.example", }, { field: "Payment method", before: "ACH", after: "Wire" }, { field: "Bank account", before: "Account ending 4821", after: "Account ending 0937", }, { field: "Payment terms", before: "Net 30", after: "2/10 net 30", }, { field: "AP contact", after: "Jordan Lee" }, ]} /> </div> );}Stacked, in an approval
One line per field with an arrow between the values, for narrow panels. Values can be any node, so a stage change reads as the old tag struck through and the new one.
Update the prompt and publish version 14
Changes 1 agent
- Prompt
- Before: Version 13After: Version 14, 2 edits
- Rollout
- Before: 100% on version 13After: 20% on version 14
- Stage
- Before: TestingAfter: Live
import { DiffTable } from "@oration/canon/components/ai/diff";import { Button } from "@oration/canon/components/button";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";export function Stacked() { return ( <div className="flex w-80 flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex flex-col gap-0.5"> <h3 className="text-13 font-medium text-foreground"> Update the prompt and publish version 14 </h3> <p className="text-xs text-muted-foreground">Changes 1 agent</p> </div> <div className="rounded-[10px] bg-muted/70 px-3 py-2.5"> <DiffTable layout="stacked" rows={[ { field: "Prompt", before: "Version 13", after: "Version 14, 2 edits", }, { field: "Rollout", before: "100% on version 13", after: "20% on version 14", }, { field: "Stage", before: <Tag color="amber">Testing</Tag>, after: <Tag color="green">Live</Tag>, }, ]} /> </div> <div className="flex justify-end gap-2"> <Button type="button" variant="ghost" size="sm" onClick={() => toast.add({ title: "Skipped", description: "Version 13 stays live.", }) } > Skip </Button> <Button type="button" size="sm" onClick={() => toast.add({ type: "success", title: "Version 14 is live for 20% of calls", }) } > Publish version 14 </Button> </div> </div> );}Counting with diffWords
Call diffWords for the parts and build your own summary, such as word counts in a version list.
- Version 14, draft16 words added, 12 removed
- Version 13, live3 words added, 1 removed
import { diffWords } from "@oration/canon/components/ai/diff";export function WordCounts() { const versions = [ { id: "v14", name: "Version 14, draft", before: "Ask the caller for the invoice number. If the payment is not yet sent, tell them it is processing.", after: "Ask for the invoice number, or its last four digits with the supplier name. If the payment is scheduled, read the run date.", }, { id: "v13", name: "Version 13, live", before: "Payment runs go out weekly.", after: "Payment runs go out Tuesdays and Fridays.", }, ]; return ( <ul className="flex w-full max-w-md flex-col divide-y divide-border rounded-xl bg-card shadow-border"> {versions.map((version) => { const parts = diffWords(version.before, version.after); const count = (type: "added" | "removed") => parts .filter((part) => part.type === type) .reduce( (sum, part) => sum + part.text.trim().split(/\s+/).filter(Boolean) .length, 0, ); return ( <li key={version.id} className="flex items-center justify-between gap-3 px-4 py-2.5" > <span className="text-13 font-medium text-foreground"> {version.name} </span> <span className="text-xs text-muted-foreground tabular-nums"> {count("added")} words added, {count("removed")}{" "} removed </span> </li> ); })} </ul> );}States#
| State | Treatment |
|---|---|
| Unchanged | No marks. Identical texts render as plain text. |
| Removed | Struck through on the red tint. |
| Added | On the green tint. |
| Replaced | A removed run followed by an added run, so the new words read right after the old. |
| New field | before left out: a dash in the before column, then the value. |
| Too long to compare | Past 250,000 word pairs, diffWords stops comparing and shows the whole old text removed and the whole new text added. |
Behavior#
diffWordssplits both texts on whitespace, keeping the whitespace as tokens, and walks a longest-common-subsequence table. Runs of the same kind merge into one part.- Whitespace-only removals are dropped and whitespace-only additions render as plain spaces, so spacing changes don't draw empty marks.
- Punctuation belongs to its word: changing weekly. to weekly, marks the whole word.
InlineDiffmemoizes the diff onbeforeandafter. It is a span, so it flows inside a paragraph.- Neither component has
"use client", so both render on the server. DiffTablevalues areReactNode, so a tag, a mono ID or a formatted amount works on either side.
Do and don't#
Remittances go to the email on the removed: invoice.added: supplier record.
removed: Remittancesadded: We removed: goadded: send added: every remittance advice to the removed: emailadded: address added: saved on removed: theadded: each removed: invoice.added: supplier's record.
- Payment method
- Before: ACHAfter: Wire
- Payment terms
- Before: Net 30After: 2/10 net 30
| Field | Before | After |
|---|---|---|
| Payment method | Before: ACH | Wire |
| Payment terms | Before: Net 30 | 2/10 net 30 |
Content#
- Title a proposed text change for what it is: Proposed prompt change, Rewritten description.
- In a
DiffTable, write field names as they appear on the record: Remit-to email, Payment method. - Mask sensitive values on both sides the same way: Account ending 4821 → Account ending 0937.
- For counts from
diffWords, write them in words people use: 2 edits, 11 words added, 6 removed. - Give the table a
captionthat names the change, such as Changes to Northwind Freight. It is read to screen readers.
Accessibility#
- Removed and added parts carry visually hidden removed: and added: prefixes, so screen readers hear the change, not a run-on sentence.
- Before and after values in
DiffTablecarry hidden Before: and After: prefixes; an empty before reads Empty. - The table layout is a real
<table>with a hidden caption, column headers and row headers. The stacked layout is a<dl>named bycaption. - Text stays at text contrast: removed text is Signal Red on its tint and added text is ink.
Design tokens#
| Token | Used for |
|---|---|
--destructive | Removed tint at 10% (20% dark), text and the 50% strikethrough |
--success | Added tint at 15% (20% dark) |
--foreground | Added text and after values |
--muted-foreground | Before values, field names, column headers |
--subtle-foreground | Before strikethrough, the empty dash and the arrow |
--border | Table header rule and row dividers |
--muted | The 70% well an inline diff sits in |
API reference#
InlineDiff
A word-level inline diff.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
beforeRequired | string | No default | The current text. |
afterRequired | string | No default | The proposed text. |
className | string | No default | Merged onto the span. |
DiffTable
Field changes. Also exported: the DiffRow and DiffPart types.
Other props spread onto Nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
rowsRequired | DiffRow[] | No default | { field: ReactNode; before?: ReactNode; after: ReactNode }[]. |
layout | "table" | "stacked" | "table" | Three columns for a page, or one line per field for narrow panels. |
caption | string | "Proposed changes" | Screen reader caption for the table, or name of the list. |
className | string | No default | Merged onto the table or list. |
diffWords
The diff itself.
| Prop | Type | Default | Description |
|---|---|---|---|
beforeRequired | string | No default | The current text. |
afterRequired | string | No default | The proposed text. |
returns | DiffPart[] | No default | { type: "same" | "removed" | "added"; text: string }[], with whitespace kept. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
diffWords isn't called anywhere in the app, and DiffTable is only used inside Approval card. caption is never set, so every table reads Proposed changes.
It is word-level only. Punctuation attaches to words, and there is no character or line mode for IDs, code or JSON.
Past 250,000 word pairs (roughly two 500-word texts) the diff gives up and shows everything removed and added.
Added words have no non-color cue for sighted users; only removed words are struck through. In grayscale an addition reads as highlighted, not as added.