Color
A near-monochrome cool-neutral palette with one quiet indigo, four semantic colors, ink steps and ten option hues.
The palette by role#
A near-monochrome cool-neutral set on hue 265, one indigo on hue 272, four semantic colors, ink steps for data, a chart set and ten option hues. Every swatch shows light on the left and dark on the right.
Neutral#
Planes, fills, ink and lines. Most of every screen is drawn from these seventeen.
--backgroundThe content plane, table cells, the app header and grid footers.
- Light
- oklch(1 0 0)
- Dark
- oklch(0.178 0.005 265)
--cardCards and panels. Equal to the plane in light, a step lighter in dark.
- Light
- oklch(1 0 0)
- Dark
- oklch(0.205 0.006 265)
--popoverMenus, popovers, selects and dialogs. Another step lighter in dark.
- Light
- oklch(1 0 0)
- Dark
- oklch(0.225 0.007 265)
--surfaceThe data-grid row hover.
- Light
- oklch(0.985 0.0015 265)
- Dark
- oklch(0.198 0.006 265)
--sidebarThe sidebar layer.
- Light
- oklch(0.978 0.0025 265)
- Dark
- oklch(0.158 0.005 265)
--sidebar-foregroundNav text in the sidebar.
- Light
- oklch(0.34 0.01 265)
- Dark
- oklch(0.78 0.008 265)
--sidebar-accentThe hover and active nav item fill.
- Light
- oklch(0.936 0.005 265)
- Dark
- oklch(0.23 0.006 265)
--mutedTint wells at 70%, kanban columns at 60%, tab tracks, meter tracks, keys.
- Light
- oklch(0.965 0.0035 265)
- Dark
- oklch(0.235 0.006 265)
--secondarySecondary buttons.
- Light
- oklch(0.962 0.004 265)
- Dark
- oklch(0.25 0.007 265)
--secondary-foregroundText on Quiet Fill.
- Light
- oklch(0.26 0.008 265)
- Dark
- oklch(0.93 0.004 265)
--accentThe highlighted item in menus, selects and comboboxes.
- Light
- oklch(0.955 0.005 265)
- Dark
- oklch(0.255 0.007 265)
--foregroundAll primary text, the tooltip fill, the base of every ink step.
- Light
- oklch(0.21 0.006 265)
- Dark
- oklch(0.955 0.003 265)
--muted-foregroundMeta text, column headers, inactive nav and tabs, resting icons.
- Light
- oklch(0.5 0.014 265)
- Dark
- oklch(0.7 0.012 265)
--subtle-foregroundEmpty-cell dashes, column-header icons, the neutral status dot.
- Light
- oklch(0.62 0.012 265)
- Dark
- oklch(0.58 0.012 265)
--borderStructural dividers only: table rules, header bottoms, section splits.
- Light
- oklch(0.918 0.005 265)
- Dark
- oklch(1 0 0 / 0.075)
--border-strongScrollbar thumbs, flow edges and handles, the dotted canvas grid.
- Light
- oklch(0.86 0.007 265)
- Dark
- oklch(1 0 0 / 0.13)
--inputInput, select and textarea strokes, the unchecked switch track.
- Light
- oklch(0.885 0.006 265)
- Dark
- oklch(1 0 0 / 0.12)
Primary#
Quiet Indigo and the two roles that travel with it. See where indigo is allowed.
--primaryThe one filled button, selection, checked controls, labelled live state.
- Light
- oklch(0.52 0.19 272)
- Dark
- oklch(0.585 0.18 272)
--ringEvery focus indicator. Matches Quiet Indigo in light and lifts in dark.
- Light
- oklch(0.52 0.19 272)
- Dark
- oklch(0.65 0.16 272)
--primary-foregroundText and icons on the indigo fill.
- Light
- oklch(0.99 0.004 272)
- Dark
- oklch(0.99 0.004 272)
Semantic#
Verdicts. Each one appears as a dot, an icon, a tint or a ring beside a text label, never as color alone.
--destructiveErrors, failed runs, Stalled. Destructive buttons are a 10% tint, never a solid fill.
- Light
- oklch(0.56 0.2 25)
- Dark
- oklch(0.68 0.18 22)
--successPassed runs, On track, Connected, a strong ICP fit.
- Light
- oklch(0.56 0.13 155)
- Dark
- oklch(0.7 0.14 155)
--warningAt risk, Invited, warnings.
- Light
- oklch(0.7 0.15 68)
- Dark
- oklch(0.8 0.14 75)
--warning-foregroundText on Caution Amber.
- Light
- oklch(0.27 0.06 60)
- Dark
- oklch(0.22 0.05 70)
--infoInformational notices and toasts.
- Light
- oklch(0.58 0.13 240)
- Dark
- oklch(0.7 0.12 240)
Ink steps#
Graphite Ink at fixed alphas, so they invert with the theme. They draw magnitudes. See ink steps in use.
--ink-80The first part of a stack, the current stage segment.
- Light
- oklch(0.21 0.006 265 / 0.8)
- Dark
- oklch(0.955 0.003 265 / 0.8)
--ink-65The default single fill: meters, progress, talk ratio.
- Light
- oklch(0.21 0.006 265 / 0.65)
- Dark
- oklch(0.955 0.003 265 / 0.65)
--ink-45The middle of a three-part stack.
- Light
- oklch(0.21 0.006 265 / 0.45)
- Dark
- oklch(0.955 0.003 265 / 0.45)
--ink-25Completed stage segments.
- Light
- oklch(0.21 0.006 265 / 0.25)
- Dark
- oklch(0.955 0.003 265 / 0.25)
--ink-15The last part of a three-part stack.
- Light
- oklch(0.21 0.006 265 / 0.15)
- Dark
- oklch(0.955 0.003 265 / 0.15)
Chart set#
For charts inside the chart container only, in this series order. Most charts lead with Chart Ink.
--chart-1The first series. Most charts lead with it.
- Light
- oklch(0.4 0.02 265)
- Dark
- oklch(0.82 0.015 265)
--chart-2The second series.
- Light
- oklch(0.66 0.11 190)
- Dark
- oklch(0.7 0.11 190)
--chart-3The third series.
- Light
- oklch(0.76 0.14 75)
- Dark
- oklch(0.8 0.13 75)
--chart-4The fourth series.
- Light
- oklch(0.6 0.19 330)
- Dark
- oklch(0.68 0.17 330)
--chart-5The fifth series.
- Light
- oklch(0.72 0.05 265)
- Dark
- oklch(0.55 0.03 265)
- Chart Ink
- Chart Teal
- Chart Amber
- Chart Magenta
- Chart Soft Ink
- Chart Ink
- Chart Teal
- Chart Amber
- Chart Magenta
- Chart Soft Ink
Contrast#
Ratios computed on every build from the OKLCH values in globals.css: OKLCH to OKLab to linear sRGB to relative luminance. Translucent layers are composited over what they sit on.
Canon checks against WCAG 2.2 AA: 4.5:1 for text, 3:1 for large text and for non-text UI such as icons, status dots, meter fills and focus rings. AAA is 7:1. Most of the system's text is 12 to 14px, so the large-text allowance rarely applies.
| Pair | Light | Dark | Where |
|---|---|---|---|
Body textforeground on background | 17.72:1AAA | 16.55:1AAA | Reading text and table cells on the plane. |
Card copyforeground on card | 17.72:1AAA | 15.71:1AAA | Text on cards and panels. |
Popover copyforeground on popover | 17.72:1AAA | 15.00:1AAA | Menus, popovers and dialogs. |
Metamuted-foreground on background | 6.00:1AA | 7.06:1AAA | Timestamps, counts, column headers, helper text. |
Meta in a wellmuted-foreground on muted/70 over card | 5.59:1AA | 6.38:1AA | Labels inside a tint well on a card. |
Nav itemsidebar-foreground on sidebar | 11.04:1AAA | 9.72:1AAA | Sidebar items at rest. |
Active nav itemforeground on sidebar-accent over sidebar | 14.68:1AAA | 14.82:1AAA | The current page in the sidebar. |
Selected rowforeground on primary/6 over background | 16.30:1AAA | 15.72:1AAA | Grid rows and list items that are selected. |
Secondary buttonsecondary-foreground on secondary | 13.92:1AAA | 13.02:1AAA | Quiet Fill Ink on Quiet Fill. |
Filled buttonprimary-foreground on primary | 5.66:1AA | 4.26:1AA large only | Indigo Paper on Quiet Indigo. |
Indigo textprimary on background | 5.82:1AA | 4.30:1AA large only | Link buttons. |
Destructive buttondestructive on destructive/10 over background (destructive/20 over background in dark) | 4.42:1AA large only | 4.64:1AA | The red tint button, 10% in light and 20% in dark. |
Error textdestructive on background | 5.15:1AA | 6.03:1AA | Field errors and failed-run messages. |
Green textsuccess on background | 4.37:1AA large only | 7.50:1AAA | Ledger Green set as words. |
Blue textinfo on background | 4.21:1AA large only | 7.18:1AAA | Note Blue set as words. |
Amber Inkwarning-foreground on warning | 5.54:1AA | 9.15:1AAA | Text on a Caution Amber fill. |
Tooltipbackground on foreground | 17.72:1AAA | 16.55:1AAA | White Plane on Graphite Ink. |
| Element | Light | Dark | Where |
|---|---|---|---|
Faint Slatesubtle-foreground on background | 3.64:1Passes 3:1 | 4.40:1Passes 3:1 | Empty-cell dashes, column-header icons, the neutral dot. Not for words. |
Focus ringring on background | 5.82:1Passes 3:1 | 5.66:1Passes 3:1 | The 2px outline and the indigo border on focus. |
Meter fillink-65 on muted | 5.28:1Passes 3:1 | 6.89:1Passes 3:1 | Ink 65 against its Well Gray track. |
Green dotsuccess on background | 4.37:1Passes 3:1 | 7.50:1Passes 3:1 | The success status dot, beside its label. |
Amber dotwarning on background | 2.76:1Below 3:1 | 9.92:1Passes 3:1 | The warning status dot, beside its label. |
Red dotdestructive on background | 5.15:1Passes 3:1 | 6.03:1Passes 3:1 | The danger status dot, beside its label. |
Pairs that fall short
Indigo Paper on Quiet Indigo is 4.26:1 in dark, and indigo link text 4.30:1, both under 4.5:1 for 14px labels. Signal Red on its 10% tint is 4.42:1 in light. Ledger Green (4.37:1) and Note Blue (4.21:1) miss AA as text in light, and the amber dot is 2.76:1 on white. DESIGN.md sets no contrast target, so these are recorded here rather than fixed. Until the tokens move, set status words in Graphite Ink beside the colored dot, as Status label does, and don't set paragraphs in semantic colors.
Four rules for color#
They keep indigo legible, data neutral and status readable in grayscale. Each is drawn below the way review catches it.
The Quiet Indigo Rule
The Ink Fill Rule
The Option Hue Rule
The Label-Beside-Color Rule
Indigo is for the decision#
Headings, icon tiles and decoration stay in ink. Indigo goes to the action, the selection and focus.
Magnitudes in ink#
Hues belong to option values#
A label beside every color#
Where indigo is allowed#
A closed list. If a use isn't on it, the answer is ink, a neutral fill or a tag.
Every indigo in a view
A running payment run with two invoices selected. Five uses of indigo, each on the list below. Uncheck rows or remove the filter and watch the count fall.
- Northwind Freightinv_8F3K2QW7$18,240.00
- Halcyoninv_2J9D4MX1$12,940.50
- Orchard Streetinv_7Q1L8RZ3$4,310.00
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { FilterChip } from "@oration/canon/components/filter-chip";import { StatusLabel } from "@oration/canon/components/status-dot";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CalendarIcon } from "lucide-react";import * as React from "react";import { AnatomyMarker } from "@/components/design/kit/lists";export function IndigoMap() { const invoices = [ { id: "inv_8F3K2QW7", supplier: "Northwind Freight", amount: "$18,240.00", }, { id: "inv_2J9D4MX1", supplier: "Halcyon", amount: "$12,940.50" }, { id: "inv_7Q1L8RZ3", supplier: "Orchard Street", amount: "$4,310.00" }, ]; const [selected, setSelected] = React.useState<Set<string>>( () => new Set(["inv_8F3K2QW7", "inv_2J9D4MX1"]), ); const [mark, setMark] = React.useState(true); const [filtered, setFiltered] = React.useState(true); const toggle = (id: string) => setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const marker = (n: number, className: string) => mark ? ( <AnatomyMarker n={n} className={cn("absolute z-10", className)} /> ) : null; return ( <div className="flex w-full max-w-lg flex-col gap-3"> <Switch label="Number every indigo" checked={mark} onCheckedChange={setMark} /> <div className="flex flex-col overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex h-11 items-center justify-between gap-2 border-b border-border px-3"> <div className="relative flex items-center gap-2"> <span className="text-13 font-medium text-foreground"> Payment run, Friday </span> <StatusLabel tone="primary" className="text-[13px]"> Running </StatusLabel> {marker(1, "-top-3 right-0")} </div> <span className="relative flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="size-1.5 rounded-full bg-primary" aria-hidden="true" /> 2 unread {marker(2, "-top-3 -left-3")} </span> </div> <div className="relative flex min-h-11 items-center px-3 py-2"> {filtered ? ( <FilterChip icon={ <CalendarIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> } field="Due" valuesLabel="This week" multiple={false} onRemove={() => setFiltered(false)} /> ) : ( <Button type="button" variant="ghost" size="sm" onClick={() => setFiltered(true)} > Add filter </Button> )} {filtered ? marker(3, "top-0 left-1.5") : null} </div> <ul className="border-t border-border"> {invoices.map((invoice, index) => { const isSelected = selected.has(invoice.id); return ( <li key={invoice.id} className={cn( "relative flex h-9 items-center gap-3 border-b border-border px-3 text-13 last:border-b-0", isSelected ? "bg-primary/6" : "hover:bg-surface", )} > <Checkbox checked={isSelected} onCheckedChange={() => toggle(invoice.id)} aria-label={`Select ${invoice.supplier}`} /> <span className="min-w-0 flex-1 truncate text-foreground"> {invoice.supplier} </span> <span className="font-mono text-xs text-muted-foreground"> {invoice.id} </span> <span className="w-24 text-right font-medium text-foreground tabular-nums"> {invoice.amount} </span> {index === 0 && isSelected ? marker(4, "top-0.5 -left-2.5") : null} </li> ); })} </ul> <div className="relative flex items-center justify-between gap-2 border-t border-border px-3 py-2.5"> <span className="text-xs text-muted-foreground tabular-nums"> {selected.size} of {invoices.length} selected </span> <div className="flex gap-2"> <Button type="button" variant="outline" size="sm"> Export CSV </Button> <Button type="button" size="sm" disabled={selected.size === 0} onClick={() => toast.add({ title: `${selected.size} invoices approved`, description: "They join Friday's payment run on Oct 2.", }) } > Approve {selected.size} </Button> </div> {marker(5, "-top-2.5 right-1")} </div> </div> </div> );}| Use | Treatment | See |
|---|---|---|
| The one filled button | Quiet Indigo fill and Indigo Paper text. Hover mixes in 9% black, 8% white in dark. | Button |
| Checked controls | Checkbox, radio and switch fills when on. | Checkbox, Switch |
| Selection | A 6% tint on selected rows; a 6% tint and a 22% inset ring on active filter chips; a 1.5 to 2px ring on a selected choice card. | Data grid, Filter chip, Choice card |
| Focus | A 3px ring at 40 to 50% with an indigo border on controls; a 2px outline at a 2px offset on bare links. | Accessibility |
| Text selection and the caret | Selection at 22%, and the caret in every input and textarea. | Styles |
| Workflow edges | Selected and running edges. A running edge is a 1.75px dashed line that marches. | Flow |
| Live state with a label | A running node or run, an active sequence, an upcoming meeting, with its word beside it. | Status label |
| The viewer's own markers | The unread dot, an @mention of you, the unsaved-changes dot. | Save bar |
| Link buttons | Indigo text that underlines on hover. | Button |
- Never on data: meters, bars, chart series, sparklines and stage tracks are ink.
- Never on icon tiles, headings, section labels, illustrations or borders for decoration.
- Never two filled indigo buttons in one view. See The One Filled Button Rule on Button.
- Tag Indigo (
--tag-indigo) is an option hue. Don't use it to fake a softer primary.
Ink steps in use#
Magnitudes are drawn in the same ink as the text, at five fixed alphas, on a Well Gray track. On most screens that leaves indigo as the only saturated color.
| Step | Utility | Use |
|---|---|---|
| Ink 80 | bg-ink-80 | The first part of a stack (closed won), the current stage segment. |
| Ink 65 | bg-ink-65 | The default single fill: meters, progress, talk ratio, the credits meter. |
| Ink 45 | bg-ink-45 | The middle of a three-part stack (commit). |
| Ink 25 | bg-ink-25 | Completed stage segments. |
| Ink 15 | bg-ink-15 | The last part of a three-part stack (best case). |
A three-part stack
Closed won in Ink 80, commit in Ink 45 and best case in Ink 15, against a target marker. Switch quarters and the segments animate over 500ms.
- Closed won$0.64M
- Commit$0.58M
- Best case$0.39M
- Target$2.00M
import { Meter } from "@oration/canon/components/meter";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function ForecastStack() { const [quarter, setQuarter] = React.useState<keyof typeof quarters>("q4"); const data = quarters[quarter]; const max = 2.4; const parts = [ { label: "Closed won", value: data.won, className: "bg-ink-80" }, { label: "Commit", value: data.commit, className: "bg-ink-45" }, { label: "Best case", value: data.best, className: "bg-ink-15" }, ]; const money = (n: number) => `$${n.toFixed(2)}M`; return ( <div className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center justify-between gap-3"> <div className="flex flex-col"> <span className="text-sm font-semibold text-foreground"> Forecast </span> <span className="text-xs text-muted-foreground"> Maya Okafor, VP of Revenue </span> </div> <SegmentedControl<keyof typeof quarters> label="Quarter" value={quarter} onValueChange={setQuarter} options={[ { value: "q3", label: "Q3" }, { value: "q4", label: "Q4" }, ]} /> </div> <Meter label={`Forecast against a ${money(data.target)} target`} max={max} target={data.target} size="lg" segments={parts} /> <ul className="flex flex-col gap-1.5"> {parts.map((part) => ( <li key={part.label} className="flex items-center gap-2 text-13" > <span aria-hidden="true" className={cn( "size-2 shrink-0 rounded-[2px]", part.className, )} /> <span className="flex-1 text-foreground"> {part.label} </span> <span className="font-medium text-foreground tabular-nums"> {money(part.value)} </span> </li> ))} <li className="flex items-center gap-2 border-t border-border pt-1.5 text-13"> <span aria-hidden="true" className="h-2.5 w-0.5 shrink-0 rounded-full bg-foreground" /> <span className="flex-1 text-muted-foreground">Target</span> <span className="text-muted-foreground tabular-nums"> {money(data.target)} </span> </li> </ul> </div> );}Stage track and a single fill
Completed stages in Ink 25 with a check, the current one in Ink 80, upcoming ones in Well Gray. The credits meter is one Ink 65 fill.
import { Button } from "@oration/canon/components/button";import { Meter } from "@oration/canon/components/meter";import { cn } from "@oration/canon/lib/utils";import { CheckIcon } from "lucide-react";import * as React from "react";export function StageTrack() { const [current, setCurrent] = React.useState(2); const [credits, setCredits] = React.useState(6200); return ( <div className="flex w-full max-w-md flex-col gap-5 rounded-xl bg-card p-4 shadow-border"> <div className="flex flex-col gap-2"> <div className="flex items-baseline justify-between gap-3"> <span className="text-sm font-semibold text-foreground"> Halcyon renewal </span> <span className="text-xs text-muted-foreground"> {stages[current]} </span> </div> <div className="flex gap-1"> {stages.map((stage, index) => ( <button key={stage} type="button" aria-label={`Move to ${stage}`} aria-pressed={index === current} onClick={() => setCurrent(index)} className="group flex min-w-0 flex-1 flex-col gap-1.5 rounded-sm text-left outline-none" > <span className={cn( "h-1 w-full rounded-full transition-colors duration-150 ease-out", index < current && "bg-ink-25", index === current && "bg-ink-80", index > current && "bg-muted group-hover:bg-ink-15", )} /> <span className={cn( "items-center gap-1 truncate text-xs sm:flex", index === current ? "flex font-medium text-foreground" : "hidden text-muted-foreground", )} > {index < current ? ( <CheckIcon aria-hidden="true" className="size-3 shrink-0" /> ) : null} <span className="truncate">{stage}</span> </span> </button> ))} </div> </div> <div className="flex flex-col gap-2"> <div className="flex items-baseline justify-between gap-3 text-13"> <span className="text-foreground">Enrichment credits</span> <span className="text-muted-foreground tabular-nums"> {credits.toLocaleString("en-US")} of 10,000 </span> </div> <Meter label="Enrichment credits used" value={credits} max={10000} /> <div className="flex items-center justify-between"> <span className="text-2xs text-muted-foreground"> Resets Oct 1 </span> <Button type="button" variant="outline" size="sm" onClick={() => setCredits((value) => Math.min(10000, value + 900)) } > Enrich 900 rows </Button> </div> </div> </div> );}Legends pair an 8px square swatch with a 13px label and a tabular value. The Meter component draws the track, the fills and the target; pass ink utilities as segment classes.
App marks#
Each app in the suite has an identity hue, shown as the short bar beside the active sidebar item and on its app tile. They are identity tints under The Option Hue Rule, never indigo.
The active mark in both themes
Pick an app. The 3px mark takes that app's hue, with a soft glow at 55%.
import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function AppMarks() { const [active, setActive] = React.useState<(typeof apps)[number]["id"]>("crm"); return ( <div className="grid w-full max-w-xl gap-3 sm:grid-cols-2"> {(["light", "dark"] as const).map((mode) => ( <div key={mode} className={cn( mode, "flex flex-col gap-0.5 rounded-xl bg-sidebar p-2 shadow-border", )} > <span className="px-2 pt-1 pb-1.5 text-xs font-medium text-muted-foreground"> {mode === "light" ? "Light" : "Dark"} </span> {apps.map((app) => { const isActive = app.id === active; const Icon = app.icon; return ( <button key={app.id} type="button" aria-pressed={isActive} onClick={() => setActive(app.id)} className={cn( "relative flex h-7 items-center gap-2.5 rounded-md px-2 text-left text-13 text-sidebar-foreground outline-none transition-colors duration-150 ease-out hover:bg-sidebar-accent focus-visible:ring-3 focus-visible:ring-ring/40", isActive && "bg-sidebar-accent font-medium text-foreground", )} > {isActive ? ( <span aria-hidden="true" className="absolute top-1.5 -left-2 h-4 w-[3px] rounded-full" style={{ background: app.mark, boxShadow: `0 0 8px -1px color-mix(in oklch, ${app.mark} 55%, transparent)`, }} /> ) : null} <Icon aria-hidden="true" className={cn( "size-4", isActive ? "text-foreground" : "text-muted-foreground", )} /> {app.label} </button> ); })} </div> ))} </div> );}| Token | App | Light | Dark |
|---|---|---|---|
--app-crm | CRM | oklch(0.62 0.16 250) | var(--tag-blue-fg) |
--app-agents | Agents Platform | oklch(0.6 0.18 300) | var(--tag-violet-fg) |
--app-contact | Contact Center | oklch(0.62 0.15 155) | var(--tag-green-fg) |
--app-tickets | Ticketing | oklch(0.72 0.15 75) | var(--tag-amber-fg) |
In light the marks are lighter than the tag inks, which are tuned for text on pale chips. In dark they alias the matching tag ink directly. App tiles pair the tag fill with the tag ink. Read the marks with bg-(--app-crm) or an inline var(); they have no Tailwind key.
The mark's glow is the only colored shadow in the system. Spend each hue only where DESIGN.md allows: the app tile and the active sidebar item.