Writing
The mechanics of Oration copy: sentence case, numbers, dates, labels, errors, empty states and the product glossary.
Case and structure#
Sentence case everywhere, nothing above a heading, and metadata separated by layout rather than punctuation.
The Sentence Case Rule
The Layout Over Punctuation Rule
Payment runs this week
Payment Runs This Week
Remittance settings
Where suppliers receive payment details.
Payments
Remittance settings
- Proper nouns keep their capitals: Cedarline, Northwind Freight, Agents Platform, Contact Center, Copilot, Nora.
- Features are not proper nouns: workflows, sequences, enrichment, procedures. See Naming.
- Headings, labels, buttons, tabs and toast titles take no closing period. Descriptions, helper text and anything with two sentences do.
- Lowercase after a colon unless what follows is a proper noun or a full sentence of its own.
- Join two pieces of metadata with a comma or a word (“Net 30, due Oct 2”), or give each its own place in the layout. Never a middle dot, pipe or slash.
Numbers#
Numerals for every count, separators from 1,000, cents wherever money is compared, and tabular figures so columns line up.
One value, four contexts
Type a value. Each line is formatted with Intl.NumberFormat the way Canon writes it in that context.
- Table cell, total
- $18,240.00
- Summary, chart axis
- $18.2K
- Count
- 18,240 credits
- Share of $24,516.00
- 74.4%
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import * as React from "react";export function NumberFormats() { const id = React.useId(); const [raw, setRaw] = React.useState("18240"); const value = Number(raw.replace(/[^\d.-]/g, "")) || 0; const rows = [ { context: "Table cell, total", text: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(value), }, { context: "Summary, chart axis", text: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1, }).format(value), }, { context: "Count", text: `${new Intl.NumberFormat("en-US").format(Math.round(value))} credits`, }, { context: "Share of $24,516.00", text: new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1, }).format(value / 24516), }, ]; return ( <div className="flex w-full max-w-md flex-col gap-3 text-left"> <div className="flex flex-col gap-1.5"> <Label htmlFor={id}>Value</Label> <Input id={id} inputMode="decimal" value={raw} onChange={(event) => setRaw(event.target.value)} className="tabular-nums" /> </div> <dl className="overflow-hidden rounded-xl bg-card shadow-border"> {rows.map((row) => ( <div key={row.context} className="flex items-center justify-between gap-4 border-b border-border px-4 py-2.5 text-13 last:border-b-0" > <dt className="text-muted-foreground">{row.context}</dt> <dd className="font-medium text-foreground tabular-nums"> {row.text} </dd> </div> ))} </dl> </div> );}| Kind | Write | Not | Rule |
|---|---|---|---|
| Counts | 3 invoices, 1,284 suppliers | three invoices, 1284 | Numerals always in UI, with a comma from 1,000. |
| Money in tables | $18,240.00 | $18240, 18,240 USD | Symbol first, separators, two decimals. A column shows cents on every row or none. |
| Money in summaries | $18.2K, $1.84M | $18.2k, 18.2 K | Compact only in summaries, stat strips and chart axes. Uppercase K and M, no space. |
| Negative money | -$412.50 | ($412.50), $-412.50 | Minus before the symbol, as Intl.NumberFormat writes it. |
| Percentages | 92.4%, 2% | 92.40 %, 2 percent | No space, at most one decimal. Write “percent” only in running prose. |
| Changes | Up 12%, +12% | 12% increase!! | In a sentence say up or down; a delta chip uses a sign and an arrow. |
| Ranges | $5,000 to $10,000, Oct 2 to Oct 9 | $5,000-$10,000 | Write “to”. A dense chart axis may use an en dash without spaces: 10–50. |
| Units | 450 ms, 1.2 s, 10 MB | 450ms, 1.2sec | A space between the number and the unit; percent is the exception. |
| Credits | 9,200 of 10,000 credits | 92% used! | Give the count and the limit; the meter shows the share. |
tabular-nums and aligns right in its column. See the Tabular Figures Rule.Dates and times#
Relative while it's recent, absolute once it's older than a week, and the full date where a person reads it as a heading.
The same events in three contexts
Switch the context. Rows stay short, sentences read naturally, headings give the full date. Every relative time keeps the exact one in its title.
- Remittance sent to Northwind Freight
- W-9 requested from Halcyon
- Payment run approved
- Orchard Street disputed INV-20438
- Halcyon's W-9 expired
- Next payment run
Now is Monday, September 28, 2:00 PM CT. Hover a time for the full date.
import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function DateFormats() { const [context, setContext] = React.useState< "row" | "sentence" | "heading" >("row"); // Today in Cedarline's workspace: Monday, September 28, 2026, 2:00 PM CT. const now = new Date("2026-09-28T14:00:00-05:00"); const events = [ { what: "Remittance sent to Northwind Freight", at: "2026-09-28T13:58:00-05:00", }, { what: "W-9 requested from Halcyon", at: "2026-09-28T11:12:00-05:00" }, { what: "Payment run approved", at: "2026-09-27T16:12:00-05:00" }, { what: "Orchard Street disputed INV-20438", at: "2026-09-24T09:30:00-05:00", }, { what: "Halcyon's W-9 expired", at: "2026-09-01T00:00:00-05:00" }, { what: "Next payment run", at: "2026-10-01T09:00:00-05:00" }, ]; const zone = "America/Chicago"; const time = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone, }); const short = (date: Date) => date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: zone, }); const long = (date: Date) => date.toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: zone, }); const full = (date: Date) => date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: zone, }); const weekday = (date: Date) => date.toLocaleDateString("en-US", { weekday: "long", timeZone: zone }); const calendarDay = (date: Date) => Date.parse(date.toLocaleDateString("en-CA", { timeZone: zone })); const format = (iso: string) => { const date = new Date(iso); const minutes = Math.round((now.getTime() - date.getTime()) / 60000); const days = Math.round( (calendarDay(now) - calendarDay(date)) / 86400000, ); if (context === "heading") return full(date); if (context === "row") { if (minutes < 0) return short(date); if (minutes < 1) return "Just now"; if (minutes < 60) return `${minutes} min ago`; if (days === 0) return `${Math.round(minutes / 60)} hr ago`; if (days === 1) return "Yesterday"; if (days < 7) return `${days} days ago`; return short(date); } if (minutes < 0) return `on ${full(date)} at ${time(date)} CT`; if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes} min ago`; if (days === 0) return `today at ${time(date)}`; if (days === 1) return `yesterday at ${time(date)}`; if (days < 7) return `on ${weekday(date)} at ${time(date)}`; return `on ${long(date)}`; }; return ( <div className="flex w-full max-w-lg flex-col gap-3 text-left"> <SegmentedControl label="Context" value={context} onValueChange={setContext} options={[ { value: "row", label: "Dense row" }, { value: "sentence", label: "Sentence" }, { value: "heading", label: "Heading" }, ]} /> <ul className="overflow-hidden rounded-xl bg-card shadow-border"> {events.map((event) => ( <li key={event.what} className="flex items-center justify-between gap-4 border-b border-border px-4 py-2.5 text-13 last:border-b-0" > <span className="min-w-0 truncate text-foreground"> {event.what} </span> <time dateTime={event.at} title={new Date(event.at).toLocaleString("en-US", { dateStyle: "full", timeStyle: "short", timeZone: "America/Chicago", })} className="shrink-0 text-muted-foreground tabular-nums" > {format(event.at)} </time> </li> ))} </ul> <p className="text-xs text-muted-foreground"> Now is Monday, September 28, 2:00 PM CT. Hover a time for the full date. </p> </div> );}| Context | Format | Example |
|---|---|---|
| Headings and greetings | Weekday, month, day | Monday, September 28 |
| Dense rows and cells | Short month and day | Sep 28 |
| Not this year | Add the year | Sep 28, 2025 |
| Times | 12-hour with minutes, a space, then AM or PM | 2:00 PM, 9:40 AM |
| Time zones | Abbreviation after the time, when people in other zones read it | 2:00 PM CT |
| Date and time together | Date, “at”, time | Thursday, October 1 at 9:00 AM CT |
| Under a minute | Just now | Just now |
| Under an hour | Minutes ago | 12 min ago |
| Earlier today | Hours ago in rows, the time in sentences | 3 hr ago, today at 11:12 AM |
| Yesterday | Yesterday, with the time in sentences | Yesterday at 4:12 PM |
| 2 to 6 days ago | Days ago in rows, the weekday in sentences | 4 days ago, on Thursday at 9:30 AM |
| A week or more | Absolute | Sep 1 |
| Future | Absolute, with the weekday when it's this week | Oct 1, Thursday, October 1 |
| Durations | Clock for media, words for spans | 3:12, 2 hr 15 min |
- Wrap every date in
<time dateTime>and put the full date and time in itstitleor tooltip. - Ticketing's dense columns may abbreviate further (5m, 3h, 2d), as Linear does. Nowhere else does.
- Don't write a date as numbers only (9/28). Month names can't be misread across regions.
- Payment terms keep their trade form: Net 30, 2/10 net 30.
Buttons, tooltips and links#
An action's label says what happens when you press it. A link's text says where it goes.
Tooltips#
A tooltip names the control in two to four words, in sentence case with no period. Shortcuts follow as keys.
A tooltip is a name, not documentation. Anything longer, or anything a person needs before they act, belongs in an Info tip or in helper text. Never put a tooltip on a natively disabled control; say why it's off in text beside it.
Link text and alt text#
Screen reader users navigate by a list of links, so each link's text has to make sense alone. Alt text describes purpose, not pixels.
Credits reset on October 1. Read the billing docs
Credits reset on October 1. To read more about billing, click here.
| Image | Alt text | Why |
|---|---|---|
| Empty state illustration | alt="" or aria-hidden | Decorative. The title beside it carries the meaning |
| Icon-only button | The action: Download invoice PDF | Functional. Name what it does, not the picture |
| Avatar or monogram beside a name | None, hidden | The name is already there |
| Uploaded W-9 preview | W-9 for Halcyon Packaging, signed Sep 14 | Informative. Say what the reader would learn |
| Chart | A one-line summary plus a data table | Informative. The ariaLabel states the finding |
Placeholders, helper text and errors#
A field tells people what it wants before they type, and tells them how to fix it next to where it broke.
A field written well
The label names the value, the placeholder is an example, the helper text explains it, and the error says what to enter. Submit it empty or with 15.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import { CircleAlertIcon } from "lucide-react";import * as React from "react";export function FieldCopy() { const id = React.useId(); const [value, setValue] = React.useState(""); const [error, setError] = React.useState(""); return ( <form noValidate onSubmit={(event) => { event.preventDefault(); const percent = Number(value); if ( value.trim() === "" || Number.isNaN(percent) || percent < 0 || percent > 10 ) { setError("Enter a discount from 0 to 10 percent."); document.getElementById(id)?.focus(); return; } setError(""); toast.add({ type: "success", title: "Payment terms saved", description: `Halcyon gets ${percent}% off when paid within 10 days.`, }); }} className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border" > <div className="flex flex-col gap-1.5"> <Label htmlFor={id}>Early-pay discount</Label> <div className="relative"> <Input id={id} inputMode="decimal" placeholder="2" value={value} onChange={(event) => setValue(event.target.value)} aria-invalid={error ? true : undefined} aria-describedby={`${id}-help${error ? ` ${id}-error` : ""}`} className="pr-8 tabular-nums" /> <span aria-hidden="true" className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-sm text-muted-foreground" > % </span> </div> <p id={`${id}-help`} className="text-xs text-muted-foreground"> Percent off the invoice when Cedarline pays within 10 days. </p> {error ? ( <p id={`${id}-error`} className="flex items-start gap-1.5 text-xs text-destructive" > <CircleAlertIcon aria-hidden="true" className="mt-px size-3.5" /> {error} </p> ) : null} </div> <div className="flex justify-end"> <Button type="submit">Save terms</Button> </div> </form> );}| Piece | Job | Example |
|---|---|---|
| Label | Names the value in two or three words. Always visible, or sr-only when the context labels it | Remittance email |
| Placeholder | An example of the format, never instructions and never the label. No ellipsis | ap@northwindfreight.com |
| Helper text | One sentence on what the value is for or what's allowed, shown before the mistake | Remittance advice goes here after each payment run. |
| Error | What went wrong and what to do, phrased as the fix | Enter an email address like ap@northwindfreight.com. |
- Errors that aren't about one field name what failed and what to do: “Couldn't save the sequence. Check your connection and try again.”
- Never blame: “Enter a supplier name”, not “You forgot the supplier name”. Avoid “invalid”, “illegal” and error codes as the message.
- Phrase hints positively: “Use letters and numbers”, not “Don't use symbols”.
- If the same error keeps firing, change the interaction instead of the words.
Empty states, confirmations and toasts#
The moments around an action: before there's anything, before something can't be undone, and right after it happens.
Empty states#
Say what this place is for and offer the next action. A search or filter that finds nothing names the query and offers a way out.
A search that finds nothing
The title quotes the query, the sentence suggests what else to try, and the action clears it. Fix the typo to see results return.
No suppliers match “Halycon”
No suppliers match
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { Input } from "@oration/canon/components/input";import { SearchIcon } from "lucide-react";import * as React from "react";export function EmptyCopy() { const id = React.useId(); const suppliers = [ "Northwind Freight", "Halcyon Packaging", "Orchard Street Foods", "Pinecrest Supply", ]; const [query, setQuery] = React.useState("Halycon"); const results = suppliers.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()), ); return ( <div className="flex w-full max-w-sm flex-col gap-3 text-left"> <label htmlFor={id} className="sr-only"> Search suppliers </label> <div className="relative"> <SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> <Input id={id} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search suppliers" className="pl-8" /> </div> <div className="min-h-44 overflow-hidden rounded-xl bg-card shadow-border"> {results.length ? ( <ul> {results.map((name) => ( <li key={name} className="border-b border-border px-4 py-2.5 text-13 text-foreground last:border-b-0" > {name} </li> ))} </ul> ) : ( <EmptyState size="sm" illustration="search" title={`No suppliers match “${query.trim()}”`} description="Check the spelling, or search by tax ID or remittance email." action={ <Button variant="outline" size="sm" onClick={() => setQuery("")} > Clear search </Button> } /> )} </div> <p role="status" className="sr-only"> {results.length ? `${results.length} ${results.length === 1 ? "supplier" : "suppliers"}` : "No suppliers match"} </p> </div> );}- First-run: a title naming what's missing (No sequences yet), one sentence on what it's for, and the action that creates one.
- Done: say the good news plainly (Nothing on hold), with no action if none is needed.
- Never park lasting information in an empty state; it disappears the moment content exists.
Confirmations#
Confirm only what can't be undone. The title asks the question with the object's name; the body says what goes and what stays; the button repeats the action.
A confirmation that answers itself
The buttons alone tell you what each choice does: Cancel payment run or Keep run, never Yes and No.
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ConfirmCopy() { const [open, setOpen] = React.useState(false); return ( <> <Button variant="destructive" onClick={() => setOpen(true)}> Cancel payment run </Button> <ConfirmDialog open={open} onOpenChange={setOpen} destructive title="Cancel the October 1 payment run?" description="14 invoices for $86,412.50 won't be paid on Thursday. They go back to Scheduled, and you can add them to the next run." confirmLabel="Cancel payment run" cancelLabel="Keep run" onConfirm={() => toast.add({ title: "Payment run canceled", description: "14 invoices are back in Scheduled.", }) } /> </> );}Toasts#
A toast confirms what just happened, in the past tense, with the object's name. Reversible actions offer Undo instead of asking first.
Past tense, with Undo
Remove an invoice and undo it, or send a remittance that fails. The title stands alone when read aloud; the description adds what changed.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function ToastCopy() { return ( <div className="flex flex-wrap items-center justify-center gap-2"> <Button variant="outline" onClick={() => toast.add({ title: "Removed INV-20431 from the run", description: "The October 1 run now pays 13 invoices.", actionProps: { children: "Undo", onClick: () => toast.add({ title: "INV-20431 is back in the run", }), }, }) } > Remove from run </Button> <Button variant="outline" onClick={() => toast.add({ type: "error", title: "Couldn't send the remittance", description: "Northwind Freight's email bounced. Check the address on the supplier record.", }) } > Send remittance </Button> </div> );}| Kind | Title | Description |
|---|---|---|
| Success | Remittance sent | Northwind Freight's AP team gets it at ap@northwindfreight.com. |
| Reversible | Removed INV-20431 from the run | With Undo. The run now pays 13 invoices. |
| Error | Couldn't send the remittance | What failed and what to do. Stays until dismissed. |
| In progress | Sending remittances | No ellipsis in a toast title. The spinner shows it's working. |
Punctuation and abbreviations#
Punctuation should disappear. These are the marks Oration uses, the ones it doesn't, and how to handle short forms.
| Mark | Rule | Example |
|---|---|---|
| Exclamation point | Never in the UI. Calm copy doesn't need it, and serious copy is undermined by it. | Payment run approved. |
| Em dash | Never in the UI. Use a period, a comma, a colon or parentheses. | Halcyon is on hold: the W-9 expired. |
| Serial comma | Oration doesn't use the Oxford comma. Add one only when a list item contains “and” or the list would misread, or rewrite the list. | Suppliers, invoices and remittances |
| Period | On sentences. Not on headings, labels, buttons, tooltips, tags or toast titles. | Toast title: Remittance sent |
| Ellipsis | One character (…), never three periods. Only on live progress labels and menu items that open a dialog for more input. | Finding email…, Move to payment run… |
| Quotation marks | Curly quotes. Quote what someone typed or said, such as a search query. | No suppliers match “Halycon” |
| Ampersand | Write “and”. Keep & only in a proper name. | Terms and conditions |
| Colon | To introduce a value, a list or an explanation. Lowercase after it. | Status: on hold |
| Middle dot, pipe, slash | Never between metadata. Use layout, a comma or a word. | Net 30, due Oct 2 |
| Form | Rule | Example |
|---|---|---|
| Everyday | Use as is. Readers know them. | CRM, CSV, API, URL, PDF, SSO |
| Domain jargon | Spell out once, or explain in an Info tip beside the term. | TTFB, STT, ICP, AP |
| Plurals | Add s with no apostrophe. | APIs, W-9s, CSVs |
| Periods | None inside acronyms. | AM, PM, US |
| Units and time | Short forms after a number, with a space. | 12 min ago, 450 ms, 10 MB |
| Latin | Don't use e.g., i.e. or etc. Write “such as”, “that is” or finish the list. | Providers such as Deepgram and Azure |
Product glossary#
One word for one thing across all four apps. If a term is here, use it as written; if you need a new one, add it here first.
| Term | Definition | Prefer | Avoid |
|---|---|---|---|
| Workspace | A team's home in Oration: its members, records, settings, billing and credits. Cedarline is a workspace. | workspace | org, account, tenant, instance |
| Record | One entry of an object, such as a company, a person, a deal or a ticket. Every record has a page. | record | entry, item, entity, object (for one record) |
| Company | An organization Cedarline sells to or works with, stored as a record in the CRM. | company, companies | account, organization, org |
| Person | A human at a company, stored as a record. Plural is people. | person, people | contact, lead, user |
| Deal | A potential sale moving through pipeline stages to won or lost. | deal | opportunity, opp |
| List | A saved collection of records of one object, chosen by hand or by filter, such as suppliers to call this week. | list | segment, group, collection |
| Table | An enrichment table: rows plus columns that look up, generate or compute values and spend credits. | table | spreadsheet, sheet, grid |
| Enrichment | Filling in missing data on a record from providers or AI, cell by cell. A column enriches; a person runs it. | enrichment, enrich | data augmentation, scraping, lookup |
| Credit | The unit enrichment and AI spend. A workspace has an included balance that resets each month. | credit, credits | tokens, points, coins |
| Sequence | Multi-step outreach on a schedule: email, LinkedIn, call and task steps, with A/B variants. | sequence | cadence, drip, campaign |
| Workflow | An automation on a canvas: a trigger, then steps and branches that run without a person. | workflow | automation (for one), zap, recipe, flow |
| Agent | AI software that does work for the team, such as Nora answering supplier calls. Agents propose; people decide. | agent, the agent, it | bot, AI teammate, assistant, she or he |
| Run | One execution of a workflow, an enrichment column or an agent task, with its own status and log. | run, test run | job, execution, instance |
| Proposal | A change an agent wants to make, shown with its evidence and waiting for a person to decide. | proposal | suggestion, request, action item |
| Approval | The decision on a proposal, and the queue on Home where proposals wait for one. | approve, approval | sign-off, accept, OK |
| Copilot | The one chat surface, in every app. It answers questions and acts through proposals. | Copilot, Ask Copilot | chatbot, the AI, AI chat |
| Procedure | Step-by-step instructions an agent follows for one task, such as verifying a caller. | procedure | script, SOP, playbook |
| Flow | A conversation graph for an agent, built on a canvas from nodes that say, ask and do. | flow | workflow, tree, IVR |
| Knowledge | Documents and sources an agent can retrieve from when it answers. | knowledge, knowledge base | training data, memory, corpus |
| Tool | An action an agent can call mid-conversation, such as looking up an invoice or scheduling a callback. | tool | function, plugin, integration |
| Campaign | Outbound calls by a voice agent to a list, such as overdue W-9 collection. | campaign | blast, dialer, sequence |
| Queue | An ordered line of conversations waiting for a person, with its routing rules. | queue | inbox, bucket, pool |
| Ticket | A tracked request that needs follow-up, with a status, an assignee and a priority. | ticket | case, issue, incident |
| Macro | A saved reply or set of actions a support agent applies in one step. | macro | canned response, template, snippet |
| Callback | A scheduled return call to a customer. The noun is one word; the verb is two: call back. | callback, call back | call-back, return call request |
| Scorecard | The criteria a conversation is graded on, such as verified the caller or confirmed the amount. | scorecard | rubric, QA form, checklist |
| Evaluation | A test that grades an agent against a scorecard or scripted conversations before it ships. | evaluation | eval, test suite, benchmark |
Words that look alike
A workflow automates work on a canvas; a flow is an agent's conversation graph. A sequence is CRM outreach to people; a campaign is a voice agent calling a list. A proposal waits for a person; a run is something that already executed.