Skip to content

Using Canon

How designers and engineers pick up Canon: imports, composition, theming and the first screen.

Importing#

Canon ships as one workspace package, @oration/canon, imported file by file. There is no barrel to import from.

The package exports map, read from packages/canon/package.json
SpecifierResolves toHolds
@oration/canon/globals.csspackages/canon/src/styles/globals.cssEvery token, utility and motion rule. The web app imports it once from app/globals.css.
@oration/canon/components/*packages/canon/src/components/*.tsxComponents, one file each. AI parts sit under ai/, voice under voice/, the prompt editor under prompt-editor/.
@oration/canon/lib/*packages/canon/src/lib/*.tsLibraries: springs, utils (cn), demo-data, font-weight.
@oration/canon/hooks/*packages/canon/src/hooks/*.tsHooks such as use-mock-query, use-dirty-form and use-fluid-hover.
Typical imports
import { Button } from "@oration/canon/components/button";import { ToolChip } from "@oration/canon/components/ai/tool-chip";import { VoiceOrb } from "@oration/canon/components/voice/orb";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { spring } from "@oration/canon/lib/springs";import { cn } from "@oration/canon/lib/utils";

Import cn from Canon, not from the cn package

cn from @oration/canon/lib/utils knows that text-13 and text-2xs are font sizes. The bare cn package, which packages/canon components use internally, treats them as colors. When you override a component's text-sm through its className, write text-[13px].

Styling with tokens#

Style with semantic Tailwind tokens, never raw colors or pixel values. The names describe a role, so light and dark come from the same class.

Design tokens
TokenUsed for
--backgroundbg-background. The White Plane: pages, table cells, the header.
--cardbg-card with shadow-border and rounded-xl. Every raised surface.
shadow-borderThe hairline lift. Edge and lift in one shadow, never a CSS border plus a shadow.
--mutedbg-muted/70 with rounded-[10px] for a tint well inside a card.
--foregroundtext-foreground. Graphite Ink for primary text.
--muted-foregroundtext-muted-foreground. Meta, column headers, resting icons.
--borderborder-border. Structural dividers only: table rules, header bottoms.
--primarybg-primary, through the default Button only. One per view.
--ink-65bg-ink-65. The default fill for meters and progress.
Type utilities
UtilityUse for
text-13Dense UI: rows, nav, toolbars, card copy.
text-smReading text and descriptions (14px).
text-xsMeta, captions and helper text (12px).
tabular-numsEvery number that changes or gets compared.
font-monoOnly machine strings: IDs, keys, codes, E.164 numbers.
Northwind Freight12 open invoices
Do. Use role tokens: bg-card shadow-border, text-muted-foreground, bg-muted/70.
Northwind Freight12 open invoices
Don't. Hard-code palette colors and borders: bg-white border border-gray-200 text-gray-500. They break in dark mode and drift from the system.

Custom utilities such as skeleton-shimmer, text-shimmer and shadow-ring are listed in Styles and utilities, and every token with its light and dark value is in Design tokens.

Your first screen#

A settings card for remittance emails, built in six steps from Canon parts. Change a field to see the save bar.

  1. Pick the page shape. Settings live on a document page with a narrow 48rem column; see Settings page.
  2. Group related settings in a SettingsSection with a title and one sentence saying what the group controls.
  3. Put the rows in a SettingsGroup, one SettingsRow per setting. Pass htmlFor so the label names the control, and point aria-describedby at descriptionId(id). Outside settings, pair a label and a control with Field.
  4. Hold the values in useDirtyForm, which keeps a saved baseline and counts what changed.
  5. Add a SaveBar. It appears when the form is dirty, counts the changes, saves on ⌘S and carries the view's one filled button.
  6. Confirm the save with a toast.

Remittance settings

Remittance

What suppliers receive after each payment run.

Supplier replies to remittance emails land here.
Adds each paid invoice as a PDF, up to 20 per email.
A daily digest of invoices held for a missing W-9 or a PO mismatch.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function FirstScreen() {    const id = React.useId();    const form = useDirtyForm(        {            replyTo: "remittance@cedarline.com",            attachInvoices: true,            notifyOnHold: false,        },        {            onSave: async () => {                await new Promise((resolve) => setTimeout(resolve, 700));                toast.add({                    type: "success",                    title: "Remittance settings saved",                });            },        },    );    return (        <div className="w-full max-w-2xl text-left">            <SettingsSection                title="Remittance"                description="What suppliers receive after each payment run."            >                <SettingsGroup>                    <SettingsRow                        label="Reply-to address"                        htmlFor={`${id}-reply`}                        description="Supplier replies to remittance emails land here."                    >                        <Input                            id={`${id}-reply`}                            type="email"                            value={form.values.replyTo}                            onChange={(event) =>                                form.set("replyTo", event.target.value)                            }                            aria-describedby={descriptionId(`${id}-reply`)}                            className="sm:w-64"                        />                    </SettingsRow>                    <SettingsRow                        inline                        label="Attach invoice copies"                        htmlFor={`${id}-attach`}                        description="Adds each paid invoice as a PDF, up to 20 per email."                    >                        <Switch                            id={`${id}-attach`}                            checked={form.values.attachInvoices}                            onCheckedChange={(checked) =>                                form.set("attachInvoices", checked)                            }                            aria-describedby={descriptionId(`${id}-attach`)}                        />                    </SettingsRow>                    <SettingsRow                        inline                        label="Email me about holds"                        htmlFor={`${id}-holds`}                        description="A daily digest of invoices held for a missing W-9 or a PO mismatch."                    >                        <Switch                            id={`${id}-holds`}                            checked={form.values.notifyOnHold}                            onCheckedChange={(checked) =>                                form.set("notifyOnHold", checked)                            }                            aria-describedby={descriptionId(`${id}-holds`)}                        />                    </SettingsRow>                </SettingsGroup>            </SettingsSection>            <SaveBar                dirty={form.isDirty}                changes={form.dirtyCount}                saving={form.saving}                onDiscard={form.reset}                onSave={() => void form.save()}            />        </div>    );}

Data and its states#

The suite runs on static mock data, read through useMockQuery so it behaves like a network call. DataState turns the query into a skeleton, an empty state, an error with Retry, or your content.

A list card that owns its states

Reload to see the skeleton, or reload with an error to see the error state and Retry. Keys are dotted and unique per component.

Payment runs this week

Loading
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { StatusLabel } from "@oration/canon/components/status-dot";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function PaymentRuns() {    const headingId = React.useId();    const [attempt, setAttempt] = React.useState(0);    const [fail, setFail] = React.useState(false);    const runs = [        {            id: "RUN-0930",            date: "Mon, Sep 28",            invoices: 38,            total: "$152,377.04",            status: "Sent",            tone: "success",        },        {            id: "RUN-0931",            date: "Tue, Sep 29",            invoices: 42,            total: "$186,420.15",            status: "Scheduled",            tone: "neutral",        },        {            id: "RUN-0932",            date: "Thu, Oct 1",            invoices: 57,            total: "$241,908.62",            status: "Needs approval",            tone: "warning",        },    ] as const;    const query = useMockQuery(        `docs.using-canon.payment-runs.${attempt}`,        () => runs,        { delay: 1200, fail },    );    const reload = (withError: boolean) => {        setFail(withError);        setAttempt((n) => n + 1);    };    return (        <div className="flex w-full max-w-lg flex-col gap-3 text-left">            <div className="flex flex-wrap gap-2">                <Button                    variant="outline"                    size="sm"                    onClick={() => reload(false)}                >                    <RotateCcwIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Reload                </Button>                <Button                    variant="outline"                    size="sm"                    onClick={() => reload(true)}                >                    Reload with an error                </Button>            </div>            <section                aria-labelledby={headingId}                className="overflow-hidden rounded-xl bg-card shadow-border"            >                <h3                    id={headingId}                    className="border-b border-border px-4 py-3 text-sm font-semibold"                >                    Payment runs this week                </h3>                <DataState                    query={{ ...query, retry: () => reload(false) }}                    skeleton={                        <SkeletonRows                            rows={3}                            avatar={false}                            className="px-2 py-1"                        />                    }                    isEmpty={(list) => list.length < 1}                    empty={{                        illustration: "schedule",                        title: "No payment runs this week",                        description:                            "Runs you schedule show here with their invoices and totals.",                    }}                    errorTitle="Couldn't load payment runs"                >                    {(list) => (                        <ul className="divide-y divide-border">                            {list.map((run) => (                                <li                                    key={run.id}                                    className="flex items-center gap-3 px-4 py-2.5"                                >                                    <div className="min-w-0 flex-1">                                        <p className="truncate text-13 font-medium">                                            {run.date}                                        </p>                                        <p className="flex gap-3 text-xs text-muted-foreground">                                            <span className="font-mono">                                                {run.id}                                            </span>                                            <span className="tabular-nums">                                                {run.invoices} invoices                                            </span>                                        </p>                                    </div>                                    <StatusLabel                                        tone={run.tone}                                        className="hidden text-muted-foreground sm:inline-flex"                                    >                                        {run.status}                                    </StatusLabel>                                    <span className="w-28 text-right text-13 font-medium tabular-nums">                                        {run.total}                                    </span>                                </li>                            ))}                        </ul>                    )}                </DataState>            </section>        </div>    );}
  • Never gate a whole page on loading. Each card, table and panel owns its own states.
  • Shape the skeleton like the final layout, using the presets in Skeleton where they fit.
  • Empty states use an Illustration, a title, one sentence and the next action. Filtered-to-nothing says which filter hid everything.
  • Errors say what failed in plain words and offer Retry. The user menu's demo controls can slow every query or fail the first attempt.

Toasts and feedback#

Every mutation gives feedback. Use toast.add({ title, description?, type?, actionProps? }) from @oration/canon/components/toast. Reversible actions offer Undo.

import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function Toasts() {    return (        <div className="flex flex-wrap justify-center gap-2">            <Button                variant="outline"                onClick={() =>                    toast.add({                        type: "success",                        title: "W-9 request sent",                        description:                            "Halcyon Packaging gets a reminder every 3 days until they upload one.",                    })                }            >                Request W-9            </Button>            <Button                variant="outline"                onClick={() =>                    toast.add({                        title: "Invoice archived",                        description: "INV-20438 from Orchard Street Foods.",                        actionProps: {                            children: "Undo",                            onClick: () =>                                toast.add({ title: "Invoice restored" }),                        },                    })                }            >                Archive invoice            </Button>            <Button                variant="outline"                onClick={() =>                    toast.add({                        type: "error",                        title: "Couldn't send remittance",                        description:                            "Northwind Freight's address bounced. Check the email on their supplier record.",                    })                }            >                Send remittance            </Button>        </div>    );}

The web app mounts one Toaster in its root layout, so call toast.add from anywhere. Toasts use the Base UI toast manager; Sonner is not installed. See Feedback.

Theming#

Light and dark come from one token set. The .dark class on the root element swaps every token; .light scopes a region back to light inside a dark page.

packages/canon/src/styles/globals.css
@custom-variant dark (&:is(.dark *):not(:is(.light, .light *)));
  • The theme preference is stored in localStorage as oration-theme (light, dark or system) and applied before first paint, so there is no flash.
  • Because tokens are roles, a screen built from them needs no dark: classes. Reach for dark: only to tune a value the token can't express.
  • Every example on this site has a preview theme switch. Check both before you ship.

Accessibility#

Base UI handles roles, focus and keyboard for the primitives. What it can't do is name things and choose the right element; that part is yours.

  • Every control has a label, visible or screen-reader only. Icon-only buttons have an aria-label and a tooltip.
  • Focus stays visible. Don't remove the ring Canon draws.
  • Hit areas are at least 24px, 32px on touch.
  • Keyboard works everywhere a pointer does, and dialogs have titles.
  • Status is never color alone; a label travels with every dot.
  • Motion respects reduced motion: springs snap and shimmer stops.

The full checklist is in Accessibility.

Finding things#

Canon has a page for every component, pattern and template. Search reaches all of them.

Keyboard interactions
KeysAction
⌘KOpen search from anywhere in Canon.
/Open search when focus isn't in a field.
Where to look
Looking forGo to
A component for a jobAll components, filterable by level, category and status.
How to solve a recurring problemPatterns, starting with Loading, empty and error.
The shape of a whole pageTemplates, starting with Document page.
A token's valueDesign tokens, read live from the stylesheet.
Everything as plain textllms.txt and llms-full.txt.

Working together#

How design and engineering share Canon, and where new code belongs.

With designers#

DESIGN.md at the repo root is the frozen visual spec. Canon explains it and shows it running; neither reinterprets it per app.

  • When the code and DESIGN.md disagree, DESIGN.md wins. The component page records the drift under known gaps instead of quietly following the code.
  • Each app gets its character from layout, density and interaction, not from new colors, fonts or radii.
  • Proposals to change the system go through the lifecycle, not through a one-off screen.

In the app or in packages/canon#

Anything used in two places goes to packages/canon. Apps compose; they don't fork.

Where new code goes
What you're writingWhere it goes
A screen, a route or product-specific wiringapps/web/src/app and apps/web/src/components/<area>.
Mock data and its lookup helpersapps/web/src/lib/data, typed next to the data.
A control or layout used in a second placepackages/canon/src/components, with a Canon page.
A hook or helper with no product knowledgepackages/canon/src/hooks or packages/canon/src/lib.
A variant of an existing componentA prop on that component, not a copy in the app.