Principles
The Well-Kept Ledger, the five canons and the named rules every screen is judged against.
The Well-Kept Ledger#
Canon's north star. Every screen should look like a ledger kept by someone careful.
Picture a good bookkeeper's ledger. Dark ink on white paper. Rows ruled with thin lines, not boxed in. Figures stacked in columns so you can run a finger down them and compare. Nothing in the margin that isn't information. And one stamp, used once, when a decision is made.
That is the whole visual idea of Oration. Graphite ink on a white plane, rows ruled in hairlines, numbers in tabular figures, and a single quiet indigo saved for the action the view exists for. It is the category standard for revenue workspaces, at the finish of Attio, Linear and Clay, played straight. People should recognize every control on sight; there is nothing novel to learn and nothing decorative to look past.
Density is high and the room stays calm. Most screens carry exactly one saturated color, and motion is quick enough that nobody waits for it. The five canons on the overview show how the work is ordered; this page holds the judgment behind it.
A ledger row, live
Select invoices and approve the run. Indigo appears only on the checked boxes, the selected rows and the one filled button.
Payment run for Thursday, October 1
4 invoices2 selected, $25,545.18
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function LedgerRow() { const headingId = React.useId(); const invoices = [ { id: "INV-20417", supplier: "Northwind Freight", color: "blue", due: "Due Oct 2", status: "Matched", tone: "success", cents: 1824000, }, { id: "INV-20431", supplier: "Halcyon Packaging", color: "teal", due: "Due Oct 5", status: "Missing W-9", tone: "warning", cents: 491250, }, { id: "INV-20438", supplier: "Orchard Street Foods", color: "orange", due: "Due Oct 6", status: "Matched", tone: "success", cents: 730518, }, { id: "INV-20442", supplier: "Pinecrest Supply", color: "violet", due: "Due Oct 9", status: "Matched", tone: "success", cents: 126400, }, ] as const; const [selected, setSelected] = React.useState<string[]>([ "INV-20417", "INV-20438", ]); const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", }); const total = invoices .filter((invoice) => selected.includes(invoice.id)) .reduce((sum, invoice) => sum + invoice.cents, 0); const toggle = (id: string, checked: boolean) => setSelected((current) => checked ? [...current, id] : current.filter((item) => item !== id), ); return ( <section aria-labelledby={headingId} className="w-full max-w-2xl overflow-hidden rounded-xl bg-card text-left shadow-border" > <div className="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3"> <h3 id={headingId} className="text-sm font-semibold text-foreground" > Payment run for Thursday, October 1 </h3> <span className="text-xs text-muted-foreground tabular-nums"> {invoices.length} invoices </span> </div> <ul> {invoices.map((invoice) => { const isSelected = selected.includes(invoice.id); return ( <li key={invoice.id} className="border-b border-border last:border-b-0" > <label className={cn( "flex min-h-11 cursor-pointer items-center gap-3 px-4 py-2 transition-colors duration-150", isSelected ? "bg-primary/[0.06]" : "hover:bg-surface", )} > <Checkbox checked={isSelected} onCheckedChange={(checked) => toggle(invoice.id, checked) } aria-label={`Include ${invoice.id} from ${invoice.supplier}`} /> <MonogramTile name={invoice.supplier} color={invoice.color} /> <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> {invoice.supplier} </span> <span className="flex gap-3 text-xs text-muted-foreground"> <span className="font-mono"> {invoice.id} </span> <span>{invoice.due}</span> </span> </span> <StatusLabel tone={invoice.tone} className="hidden text-muted-foreground sm:inline-flex" > {invoice.status} </StatusLabel> <span className="w-24 text-right text-13 font-medium text-foreground tabular-nums"> {money(invoice.cents)} </span> </label> </li> ); })} </ul> <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border bg-muted/40 px-4 py-2.5"> <p className="text-13 text-muted-foreground tabular-nums"> <span className="font-medium text-foreground"> {selected.length} </span>{" "} selected, {money(total)} </p> <Button disabled={selected.length === 0} onClick={() => toast.add({ type: "success", title: "Payment run approved", description: `${selected.length} ${selected.length === 1 ? "invoice" : "invoices"} for ${money(total)} go out Thursday, October 1.`, }) } > Approve payment run </Button> </div> </section> );}| In the ledger | In Canon |
|---|---|
| Graphite ink on white paper | text-foreground on bg-background, with light and dark drawn from one token set. See Color. |
| Ruled lines, not boxes | border-border hairlines between rows. Raised surfaces take their edge from shadow-border, never a CSS border. See Elevation. |
| Figures in columns | tabular-nums on every number that changes or gets compared, and numeric columns align right. See Typography. |
| Marginal notes | Meta in 12px text-muted-foreground, laid out beside the value rather than joined with dots. |
| The stamp | One filled indigo Button per view, for the decision. |
Product principles#
What Oration GTM is for, from PRODUCT.md. These decide what a screen must contain before anything is drawn.
1GTM truth over toy UI
Screens show the jobs revenue and support teams actually do: decide on proposals, keep pipeline current, enrich accounts, run outreach, automate handoffs.
Real Cedarline records with real consequences. No empty shells, no placeholder charts.
2One workspace language
Navigation, records and views behave the same in the CRM, the Agents Platform, the Contact Center and Ticketing, and in whatever ships next.
Reuse the shell, the header and the page shapes. A new app gets its character from layout and density, never from new colors or fonts.
3Demo-ready by default
The suite runs on sample data with no backend, so it has to hold up in a live conversation on its own.
Every data component has loading, empty and error states, and demo controls can replay them.
4Shared craft
Screens are built from
@oration/canonand the guidance here, not from one-off markup that bypasses the system.Anything used in two places moves to
packages/canonand gets a Canon page.5Canon is the source agents build from
If a rule, an API or a pattern matters, it is written in Canon and reaches the plain-text digests agents read.
Where the docs and the code disagree, the page says so in its known gaps.
6Phased depth
Credible end-to-end slices come before exhaustive settings or billing parity.
Finish one flow deeply, with its states and copy, before starting the next one wide.
Design principles#
How the system looks and behaves. When two good options compete, these break the tie.
Dense and calm#
Show a lot without raising the volume. Small type, tight rows and hairlines carry the density; one color and short motion keep the room quiet.
| Measure | Value | Where |
|---|---|---|
| Dense text | 13px | Table cells, nav, toolbars, list rows |
| Reading text | 14px | Descriptions, dialog copy, settings help |
| Meta | 12px | Timestamps, counts, helper text |
| Controls | 32px | Buttons, inputs, selects; 28px in toolbars |
| Grid rows | 36px | Default density; 32px compact, 44px comfortable |
| Card padding | 16px | Cards, rails, popover bodies |
| Section gap | 32px | Between page sections |
Familiar affordances over novelty#
A sidebar, breadcrumbs, a command menu on ⌘K, tabs, a data grid, a sheet for details. People already know how these work, so Canon draws them the expected way and spends its craft on finish instead of invention. No smuggled quirks.
Before inventing a control, check the catalog. If the job is common, a component already does it, and its page says when not to use it.
One indigo#
Indigo marks the decision, the selection, focus and labelled live state. Because it is rare, it is legible: the eye finds the next action without reading.
AI everywhere, without noise#
AI sits where the work is: a draft button beside a free-text field, a summary on a record, an approval card for an agent's proposal. It looks like the rest of the product and never takes over the screen. The Copilot is the one chat surface.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function AiInField() { const id = React.useId(); const [note, setNote] = React.useState(""); const previous = React.useRef(""); return ( <div className="flex w-full max-w-md flex-col gap-2 text-left"> <div className="flex items-center justify-between gap-3"> <label htmlFor={id} className="text-sm font-medium text-foreground" > Note to supplier </label> <AIGenerateButton label="Draft" onGenerate={() => "Hi Halcyon team, your invoice INV-20431 is on hold because we don't have a current W-9 on file. Upload one through the supplier portal and it will go out in the next payment run." } onResult={(text) => { previous.current = note; setNote(text); }} onUndo={() => setNote(previous.current)} /> </div> <Textarea id={id} value={note} onChange={(event) => setNote(event.target.value)} placeholder="Explain why the invoice is on hold" className="min-h-24" /> </div> );}Every component owns its states#
A card that shows data draws its own skeleton, empty and error states in place. The page around it keeps working while one part loads or fails. See Loading, empty and error.
Loading
Empty
Nothing on hold
Error
Couldn't load invoices on hold
import { EmptyState, ErrorState } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { toast } from "@oration/canon/components/toast";export function OwnedStates() { return ( <div className="grid w-full gap-3 text-left md:grid-cols-3"> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Loading </p> <SkeletonRows rows={3} avatar="square" className="px-2 py-1" /> </div> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Empty </p> <EmptyState size="sm" illustration="done" title="Nothing on hold" description="Invoices that fail matching wait here for review." /> </div> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Error </p> <ErrorState size="sm" title="Couldn't load invoices on hold" onRetry={() => toast.add({ title: "Retrying", description: "Loading invoices again.", }) } /> </div> </div> );}Education without tours#
Every page explains itself in its first viewport: a title and description, a slim page intro on overview pages, an info tip beside jargon. Empty states teach the first action. Nothing interrupts the work to teach it. See Education.
Step 2 of 6
This is where you set early-pay discounts.
Layout over punctuation#
Space, alignment and columns separate information. Dots, pipes and slashes between values make people parse a string the layout should have parsed for them.
Named rules#
17 rules, each named so a review can cite it. Every Canon page lists the ones that govern it, and /design/llms.txt hands them to agents word for word.
Color#
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.
Explained in Color
The Option Hue Rule
The ten categorical hues belong to select-option values (stage, lifecycle, tier, list and attribute options) and to identity tints on avatars, monogram tiles and the four app marks. An app hue appears in two places: the glyph tile, and the 3px pill beside the active sidebar item. Categorical hues never color-code sections, navigation text, status or charts.
Explained in Tag
Components#
Surfaces#
The Hairline-and-Lift Rule
A raised surface takes its edge and its lift from one composite shadow (shadow-border), never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.
Explained in Elevation
The Tint Well Rule
Inside a card, a sub-region is a Well Gray tint at 70% with a 10px radius, never a second bordered or shadowed card. No card is ever nested in a card.
Explained in Well
The Scroll Edge Rule
Clipped content shows its edge. Horizontal tab and view rows fade the clipped side over 2rem, and the last pinned grid column casts its edge shadow only once the grid scrolls sideways.
Explained in Overflow fade
Typography#
The Thirteen-Fourteen Rule
Dense UI is 13px, reading text is 14px, meta is 12px. 11px is for footnotes only, and nothing is set smaller.
Explained in Typography
The Tabular Figures Rule
Every number that changes or gets compared (amounts, counts, percentages, times, credits) is set in tabular figures, and numeric columns align right.
Explained in Typography
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.
Explained in Typography
Data#
The Ink Fill Rule
Magnitudes are drawn in ink steps (80, 65, 45, 25, 15) on a Well Gray track: meters, progress, the stage track, ICP bars and the forecast bar. A fill turns semantic only when the value is itself a verdict, such as a strong ICP fit in Ledger Green. It is never indigo.
Explained in Color
The Label-Beside-Color Rule
Status is never color alone. A dot, tint or ring always travels with a text label (On track, At risk, Running, Passed), so the state reads in grayscale.
Explained in Status label
The Owned States Rule
Every component that shows data owns its loading, empty and error states, with a skeleton shaped like its final layout. A whole page is never gated on loading.
Explained in Loading, empty and error
Content#
The Sentence Case Rule
Everything is sentence case. No eyebrow or kicker label above a heading, no uppercase labels, no letter-spaced captions.
Explained in Writing
The Layout Over Punctuation Rule
Metadata is never joined into dot-separated strings (A · B · C). Separate it with layout, commas or words.
Explained in Writing
Motion#
The Three Springs Rule
Motion uses spring.fast, spring.moderate and spring.slow, with exits one tier faster that never bounce. No other timings exist except the skeleton reveal, shimmer, the voice orb and meter fills, and keyboard-driven surfaces don't animate.
Explained in Motion
The Skeleton Reveal Rule
Every skeleton that stands in for loading content reaches the screen through DataState (data from a query) or SkeletonReveal (any other load), which pulse it, then cross-fade and cross-blur to the content over 400ms. Never loading ? <Skeleton /> : content, an AnimatePresence fade or a FadeSwap for a load. Suspense fallbacks, static skeleton galleries, AILoader and button spinners are not reveals.
Explained in Loading, empty and error
The Swap-In-Place Rule
A label, icon or number that changes while people watch changes in place, never with a bare conditional. Short labels go through TextSwap, two different icons through IconSwap, two states of one line icon through IconMorph, and figures through AnimatedNumber. Text decided once (dialog titles, plurals, empty states), sentences, tooltips, menu items and text-shimmer labels stay plain.
Explained in Labels and icons that change
What Canon refuses#
Patterns that are never right here, whatever the screen. They read as generic dashboard work and break the ledger.
- Hero-metric card grids (big number, tiny label, trend chip). Show metrics as stat strips, rows, tables or real charts.
- Gradient text, glass, backdrop-blur decoration and thick colored left borders. The only gradient-clipped text is the shimmer on an in-progress label; the only animated gradient is the voice orb.
- Nested cards. A sub-region inside a card is a tint well.
- Solid red destructive buttons. Destructive is a 10% red tint with red text.
- Bare lucide icons in circles as empty states. Empty states use an Illustration, a title, one sentence and the next action.
- Guided tours, coach marks, modal walkthroughs and confetti.
- Emoji in UI, invented customer logos, testimonials, revenue metrics and compliance claims.
- Animating keyboard-driven surfaces such as the command palette or J and K moves, and entering from scale 0.
Hero-metric grids#
- Invoices processed
- 1,284Up 12%
- Matched automatically
- 92.4%
- On hold
- 37
Nested cards#
Northwind Freight
Northwind Freight
Gradient text#
Payment runs
Payment runs
Solid red buttons#
Using the rules#
The rules are short so they can be quoted. A review that says “this breaks the Tint Well Rule” is faster than a paragraph of taste.
| Where | What it does with them |
|---|---|
| This page | Lists every rule, grouped by area, with a link to its full explanation. |
| Each Canon page | Repeats the rules that govern it, in its rules section. |
| llms.txt | Hands every rule and refusal to agents verbatim, from the same source file. |
| Known gaps | Records where a component's code still breaks a rule, so nobody copies the drift. |
When the code and the rules disagree
DESIGN.md is the frozen spec. If a component still breaks a rule, the rule wins and the component page says so under known gaps.