Skip to content

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 invoices

2 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>    );}
How the ledger maps to Canon
In the ledgerIn Canon
Graphite ink on white papertext-foreground on bg-background, with light and dark drawn from one token set. See Color.
Ruled lines, not boxesborder-border hairlines between rows. Raised surfaces take their edge from shadow-border, never a CSS border. See Elevation.
Figures in columnstabular-nums on every number that changes or gets compared, and numeric columns align right. See Typography.
Marginal notesMeta in 12px text-muted-foreground, laid out beside the value rather than joined with dots.
The stampOne 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.

  1. 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.

  2. 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.

  3. 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.

  4. 4Shared craft

    Screens are built from @oration/canon and the guidance here, not from one-off markup that bypasses the system.

    Anything used in two places moves to packages/canon and gets a Canon page.

  5. 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.

  6. 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.

The numbers behind density
MeasureValueWhere
Dense text13pxTable cells, nav, toolbars, list rows
Reading text14pxDescriptions, dialog copy, settings help
Meta12pxTimestamps, counts, helper text
Controls32pxButtons, inputs, selects; 28px in toolbars
Grid rows36pxDefault density; 32px compact, 44px comfortable
Card padding16pxCards, rails, popover bodies
Section gap32pxBetween 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.

Do. Give the view one filled button and make every other action outline, secondary, ghost or link.
Don't. Fill every action. When everything is indigo, nothing is the next step.

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

Invoices that fail matching wait here for review.

Error

Couldn't load invoices on hold

The request didn't finish. Try again in a moment.
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.

Early-pay discount
Do. Put the explanation beside the term, one click away, and let people keep working.
Early-pay discount

Step 2 of 6

This is where you set early-pay discounts.

Skip tourNext
Don't. Stop the screen with a coach mark that points at a control and counts steps.

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.

Northwind FreightNet 30Due Oct 2$18,240.00
Do. Give each value its own place, or join two with a comma or a word.
Northwind FreightNet 30 · Due Oct 2 · $18,240.00
Don't. Join metadata into a dot-separated string.

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#

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.

Explained in Button

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
Do. Show metrics as a stat strip, a row or a real chart, at the size of the text around them.
Invoices1,284+12%
Matched92%+3%
On hold37+8%
Don't. Build a grid of cards with a big number, a tiny label and a trend chip.

Nested cards#

Northwind Freight

Open invoices$24,516.00
Do. Split the inside of a card with a tint well: Well Gray at 70%, 10px corners, no edge.

Northwind Freight

Open invoices$24,516.00
Don't. Put a second lifted card inside the first. The edges stack up and the hierarchy blurs.

Gradient text#

Payment runs

Do. Set headings in Graphite Ink. Weight and size carry the hierarchy.

Payment runs

Don't. Clip a gradient into a heading. The only gradient-clipped text is the shimmer on an in-progress label.

Solid red buttons#

Do. Draw destructive actions as a 10% red tint with red text, and confirm them. See Destructive actions.
Don't. Fill the button solid red. It shouts louder than the primary action and reads as an alarm.

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 the rules show up
WhereWhat it does with them
This pageLists every rule, grouped by area, with a link to its full explanation.
Each Canon pageRepeats the rules that govern it, in its rules section.
llms.txtHands every rule and refusal to agents verbatim, from the same source file.
Known gapsRecords 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.