Foundations

Colors

The Comitor palette is anchored by a warm gold — restrained as an accent, never as a background flood. Every value below is declared once, in @comitor/ui/styles.css, and reaches your code as a Tailwind class.

Two things on this page decide whether a component you write is correct: the three-role contract, and the fact that the package ships two palettes. Read those two sections before picking a colour.

The three-role colour contract

This is the single most important rule in the system. Every hue — brand, status, accent, per-app accent — exists in three variants, and the suffix tells you exactly where it is allowed to appear. Picking the wrong one is a contrast bug, not a taste question.

TokenRoleMeasured againstExample
--xFills a background — solid, or a /5–/20 tintNothing. It is the thing being measured against.bg-teal · bg-success/15
--x-foregroundText and icons sitting on a solid --x fill--x itself, at 4.5:1bg-destructive text-destructive-foreground
--x-inkThat same colour on the page background: text, standalone icons, borders, active underlines--background / --card / --popover / --sidebar, and the tint of itself, at 4.5:1 for text and 3:1 for icons and borderstext-success-ink · border-red-ink

1 · the bare token fills a background

bg-info
bg-info/15

2 · the -foreground step sits on a solid fill

ApprovedDeletePending

3 · the -ink step goes on the page background

Payment receivedOverdueActive tabField is required

Wrong, and the fix

WrittenWhat breaksInstead
text-successA fill token used as text: 3.13:1 on the page background, 2.69:1 on its own /15 tint.text-success-ink
text-red for an error line4.38:1 on the page background, under AA — and a raw fill token does not move. High contrast darkens --destructive-ink to #9A3537; text-red stays where it is.text-destructive-ink
bg-gold-300 for a switch that is onLocks the component to the default palette — high contrast can no longer move it.bg-control-on
bg-ash-300 for a switch that is offSame reason. A raw scale class does not follow the palette.bg-switch-track
bg-muted for the unfilled part of a slider--slider-track exists for exactly this. It points at --muted in the default palette, so the two render identically today — and diverge in high contrast, where the track is lifted to --control-edge and bg-muted stays put.bg-slider-track
text-ink for a checkbox tickBoth are ink today, but --control-check is the one override point for the tick: an app that darkens --control-on flips it there, and text-ink would not follow.text-control-check
border-border on a control--border is decorative and deliberately below 3:1 in every palette.border-input, or border-choice-edge for checkbox and radio
text-app-accent for an active nav markThe default app accent is gold #E8B824 — 1.7–1.9:1 on the sidebar.text-app-accent-ink

Every ratio in that table is measured in the default palette, light mode — the state an app is in with nothing set. Switching palettes moves the numbers but does not rescue a single row: each one is wrong by role, not by value, and the two palettes differ only in the value behind a name.

Where the contract binds, and where the approved design wins

The -ink rule binds strictly on the tonal clusters comitor-ds never had: Badge success and warning, StatusPill, LetterAvatar, StatCard, Toast. Those are the places where text stands on a tint of its own colour, and the ink step is the only thing keeping it readable.

Where comitor-ds does own the primitive — Button, Input, Switch, Checkbox, Tabs, Alert, Form — the approved design is the source of truth and the ink step has been taken out of the render path. That is why, in the default palette, --primary-ink is just --primary and --destructive-ink is just --destructive. Both are still real, public tokens, and both still move in the high-contrast palette — the default palette simply declines to darken them.

The practical consequence: keep writing the -ink name everywhere the contract says to. On the brand and destructive pair it costs nothing today and is the one place an app can override to get a darker brand step of its own.

Two palettes, one set of components

The package ships two complete palettes. Which one is live is a user setting, independent of light and dark — each palette has its own light and dark branch, so there are four colour states in total.

Default

The approved comitor-ds palette. This is the source of truth for how the system looks, and it wins any conflict with a measurement.

Active when nothing is set. It knowingly carries a handful of pairs below WCAG thresholds — listed further down this page.

High contrast

The palette measured to clear WCAG, reached by <html data-contrast="high">. Borders, switches and secondary text darken; the brand hue does not change. Both palettes are measured by the package's gate; this is the one it fails the build over.

The user turns it on in an app's settings. It exists for dim screens, cheap panels and outdoor use — where the default palette stops being legible.

app/layout.tsx
// app/layout.tsx — a Server Component. ContrastProvider renders its own
// anti-flash script inside <body>; nothing goes in <head>.
import { ContrastProvider, ThemeProvider } from '@comitor/ui/shell'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider>
          <ContrastProvider>{children}</ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
settings page
'use client'

// A settings page. Default labels are Vietnamese, so pass English ones explicitly.
import { ContrastToggle } from '@comitor/ui/shell'

export function DisplaySettings() {
  return (
    <ContrastToggle
      labels={{
        label: 'High contrast',
        description: 'Darkens borders, switches and secondary text.',
      }}
    />
  )
}

What this costs you when writing a component

Both palettes differ only in the values of the CSS variables. There is no data-contrast: branch anywhere in a component — which is exactly why no component can ever be forgotten by a palette. The condition for that is absolute:

Colour must go through a role token. A component that writes bg-gold-300, bg-ash-300 or text-gold-400 where a role token belongs is pinned to one palette, and is silently wrong in the other. Nothing fails to compile; the class simply does not move. The raw scales below are for brand surfaces and for this documentation site's own chrome — not for shipped components.

And the class must be written out in full. Never assemble one at runtime — `bg-${color}-300`, `text-${tone}-ink` — anywhere, in any file. Tailwind scans source text statically: it never sees the assembled string, so the utility is never generated and is purged out of your app bundle. This one is quieter than the wrong-token bug, because the element renders with no colour at all rather than the wrong one. Pick from a lookup table of complete class strings instead.

Brand — gold scale

Seven steps. gold-300 is --primary in light, gold-200 is --primary in dark. Everything else in the scale exists because a role token points at it.

Brand neutral

The brand book ink/paper pair. Separate from the ash scale — these are warm, ash is not. --background and --card are plain #FFFFFF in light and #0E1116 / #16181D in dark.

Neutrals — the ash scale

Six steps only: 50 / 100 / 300 / 500 / 700 / 900. The gaps are deliberate. If this scale were still called gray-*, then gray-200/400/600/800 would silently fall through to Tailwind v4 built-in gray — which is blue-tinted, while Comitor grey is violet-tinted — and the two would mix inside one component. Renaming the namespace to ash-* means a missing step is a build-time nothing rather than a runtime colour shift. gray-* is not part of this design system.

Accent colours — the fill role

These are chosen to be a background: a solid fill, or a /5–/20 tint. Never use them as text. Note there is no --red-foreground: solid #D64545 carries neither white (4.38:1) nor ink (4.32:1), so a red fill that has to hold text goes through --destructive instead.

Accent colours — the ink role

The same five hues, frozen at a step dark enough to be read on the page background or on a /5–/20 tint of themselves. In dark mode each of these points back at the fill colour, so text-teal-ink is written once and is correct in both themes. Light-mode hexes shown.

Overriding a token

An app is allowed to redeclare any of these variables in its own CSS. Three of them come with an obligation attached, because a second value elsewhere in the file was derived from the one you are changing and will not follow it.

Change an accent, recompute its ink step

--teal-ink, --green-ink and --red-ink are literal hexes. They are the frozen result of color-mix(in srgb, <hue>, --ink 30–40%), not a live expression — so overriding --teal repaints every tint and leaves the text on top of them exactly where it was. Set the ink step in the same block, and measure it twice: on the page background and on a /15 tint of your new colour. Both readings have to clear 4.5:1; the tint is usually the tighter one.

The other two hues fail the same way for a different reason. --yellow-ink borrows --gold-500 rather than deriving from --yellow, and --navy-ink is a separate hex in dark. Neither tracks its fill either.

Why freeze them instead of deriving them

Three reasons, and they are worth knowing before you decide the indirection is a mistake. One: a frozen token is a single place to re-measure when an accent moves — the alternative is re-measuring wherever the mix happens to be written. Two: no component has to repeat a color-mix() string, which is how the percentage drifts apart between Badge, LetterAvatar and Input without anyone noticing. Three: a real hex is something tokens.ts can mirror, and canvas, HTML email and PDF have no CSS engine to resolve a mix for them.

The table row surfaces are pre-composited

--table-row-stripe and --table-row-hover are flattened composites of two other tokens: --muted at 30% and at 50%, already composited over --card. In light that is #FDFDFD and #FBFBFC; in dark, #181B20 and #1A1D23. Change --muted or --card and these two silently fall out of sync — nothing errors, the zebra stripe just stops matching the surface it is supposed to be a shade of. Recompute both.

They are opaque by necessity rather than by preference. A pinned column takes bg-inherit from its row, so any alpha left in the row background makes the pinned cell translucent and the scrolling columns underneath read straight through it. And they are written as hex rather than as color-mix() for the same reason the ink steps are: the package's contrast gate reads tokens as literal hex values, and a token it cannot parse is a token it cannot check.

A per-app accent is a CSS declaration, not an inline style

Each app in the ecosystem paints its own accent. Declare it in the app's stylesheet after the package import, with a .dark branch — not as an inline style on <body>. A single inline value has nowhere to put a dark branch, and an accent dark enough to read on white is almost always too dark to read on #0E1116.

app/globals.css
/* app/globals.css — after @import "@comitor/ui/styles.css" */

:root {
  --app-accent: #1F8A8B;            /* solid fill and /5–/20 tints only        */
  --app-accent-foreground: #0E1116; /* text on that solid fill      → 4.56:1   */
  --app-accent-ink: #1A6668;        /* the app colour on the page   → 6.68:1   */
}

.dark {
  --app-accent: #2DD4BF;
  --app-accent-foreground: #0E1116; /* 10.16:1                                 */
  --app-accent-ink: #2DD4BF;        /* 10.16:1 on --background                 */
}

Three variables, not two. Drop --app-accent-ink and every active sidebar mark and PageHeader icon frame falls back to the default gold — readable, but the wrong colour, and nothing will tell you. The package deliberately does not derive the ink step from --app-accent by color-mix(): every hue needs a different coefficient, and an automatic derivation produces a ratio nobody has measured. Add --app-accent-foreground too whenever your accent is lighter or darker than gold.

A known limit of the contrast gate: every token needs a :root default

The package's colour gate can be pointed at an app's own overridden stylesheet (it reads COMITOR_UI_STYLES and COMITOR_UI_SRC). When you do that, know what it reads: four blocks only — :root, .dark, [data-contrast="high"] and .dark[data-contrast="high"]. It never opens [data-density] or [data-font-size].

So a sizing token declared only inside a density or text-size block is, to the gate, an empty string. That matters because the gate decides how hard to judge a fill from the shape the rule draws: a pinned axis of 24px or less, or a full-width bar, is strong evidence that this is a mark carrying meaning, and a mark owes 3:1. A length it cannot resolve leaves every one of those tests false, so the mark is read as an ordinary surface and the threshold it owed is never applied. The failure direction is the bad one: not a false alarm, a false pass.

The rule that follows: every one of the five density tokens — --row-py, --menu-item-py, --list-item-py, --nav-item-py, --table-head-h — carries its comfortable value in :root, and the density block only rewrites it. Add a sixth token the same way. Declaring it in the density block alone works perfectly in the browser, which is exactly what makes the omission easy to ship.

Role tokens

These are the names a component reaches for. Every one of them is declared in the @theme inline block of styles.css, which is what turns them into Tailwind utilities. The list below is the shipped set — if a name is not here, it does not exist.

The word inline is load-bearing. It embeds var(--x) into the generated utility, so the utility resolves the variable in the browser and .dark { --primary } still repaints it at runtime. A plain @theme freezes the value at build time — every utility would bake in the light-mode hex and dark mode would simply stop working. If you fork styles.css, keep the keyword.

Control edges

VariableUtilityRole
--input / --control-edgeborder-input · border-control-edgeBorder of an input, textarea, select, combobox, OTP slot, outline button, outline badge. --control-edge points at --input.
--control-edge-strongborder-control-edge-strongSame border on hover and active. ash-300 in light, ash-500 in dark.
--choice-edgeborder-choice-edgeBorder of an unchecked checkbox or radio. Separate token because here the border is the whole control.
--choice-edge-strongborder-choice-edge-strongHover step for the above. ash-500 — ash-300 would be lighter than the resting state.
--input-filldark:bg-input-fillThe faint wash inside a dark-mode input. Transparent in light and in high contrast.
--border / --sidebar-borderborder-border · border-sidebar-borderDecorative rules only — card frames at rest, table lines, menu dividers. Carries no information, so it is under no threshold.

Controls that are on or selected

VariableUtilityRole
--control-onbg-control-onSwitch track when on, checked checkbox, radio dot, filled part of a slider, selected calendar day.
--control-knobbg-control-knobSwitch knob and radio dot. White in the default palette, both themes.
--control-checktext-control-checkThe checkmark glyph and the label of a selected calendar day. Ink in both palettes — 10.19:1 on the default gold, 5.77:1 on the darker high-contrast gold. Separate from the knob so that an app which darkens --control-on can move the tick without repainting the switch knob.
--switch-trackbg-switch-trackSwitch track when off. ash-300 in light, ash-700 in dark.
--slider-trackbg-slider-trackUnfilled slider track. A different step from the switch track — the two cannot share a token.
--ringoutline-ring · ring-ringFocus ring. Deliberately still --primary in both palettes — the only control token the high-contrast lift skips. That is a decision, not an oversight: a focus indicator is put under a contrast threshold by WCAG 2.2 at AAA, and AAA is outside what this package targets. So the ring keeps the brand hue instead of darkening away from it.

Colour on the page background

VariableUtilityRole
--primary-inktext-primary-inkBrand gold when it has to sit on the page. Equal to --primary in the default palette; gold-500 in high contrast light.
--link-ink / --link-ink-hovertext-link-inkButton variant link. Split from --primary-ink because the approved design uses gold-400 for link text but gold-300 for focus rings.
--destructive-inktext-destructive-inkRed error text on the page — input helper text, form error lines.
--app-accent-inktext-app-accent-inkThe per-app accent on the page: sidebar active marks, PageHeader icon frames. An app that overrides --app-accent must override this too.
--muted-foregroundtext-muted-foregroundSecondary text: placeholders, descriptions, chart axis labels.

Status triples

VariableUtilityRole
--success / -foreground / -inkbg-success · text-success-foreground · text-success-inkAliases green. Foreground is ink in both themes — white on green is only 3.13:1.
--warning / -foreground / -inkbg-warning · text-warning-foreground · text-warning-inkAliases yellow. The hardest hue: --warning on white is 1.59:1, which is why the ink step exists.
--info / -foreground / -inkbg-info · text-info-foreground · text-info-inkAliases teal. Ink-on-teal at 4.56:1 is the tightest passing pair in the package.
--destructive / -foreground / -hoverbg-destructive · text-destructive-foreground · hover:bg-destructive-hoverIts own token, not an alias of red. --destructive-hover must be a token because the two palettes move it in opposite directions.

Surfaces

VariableUtilityRole
--table-row-stripebg-table-row-stripeZebra stripe on striped tables. Opaque by necessity — pinned cells inherit the row background.
--table-row-hoverbg-table-row-hoverRow under the cursor. Also opaque, and one value for both striped and plain rows.
--sidebar + 4 morebg-sidebar · text-sidebar-foreground · bg-sidebar-accent …The sidebar has its own surface family so app chrome can drift from page chrome without arbitrary values.
--app-accent / -foregroundbg-app-accent · text-app-accent-foregroundSolid app-accent fill and the text on it. Declared per app in CSS with a dark branch, never as an inline style.

Also shipped and following the same contract: the shadcn semantic set (--background, --foreground, --card, --popover, --primary, --secondary, --muted, --accent, each with a -foreground), the five chart steps --chart-1 … --chart-5, and the four elevation steps shadow-surface / control / card / overlay.

Contrast

The default palette does not clear every WCAG threshold, and pretending otherwise would send someone off to "fix" a value that was chosen on purpose. The gaps below are recorded decisions. The place to raise them is the high-contrast palette, not the default one.

Known gaps in the default palette

PairRatioThreshold it misses
Input border (--input / --control-edge) on the page background1.16:1None applies
Checkbox and radio border (--choice-edge) on the page background2.99:11.4.11 · 3:1
Switch track when on (--control-on) vs page background and vs knob1.86:11.4.11 · 3:1
Active tab underline and input focus border (gold)1.86:11.4.11 · 3:1
--muted-foreground on --muted3.58:11.4.3 · 4.5:1
White text on --destructive4.38:11.4.3 · 4.5:1
Button variant link (--gold-400) on the page background3.15:11.4.3 · 4.5:1
Badge destructive — --red on red/103.82:11.4.3 · 4.5:1

Those eight rows are the design-system record, copied from the block at the top of styles.css. They are decisions about tokens, not a count of places. Counting the places is the gate's job, and until 1.6.0 it could not do it here: it merged the [data-contrast="high"] block into both of its variable tables, so every run measured the high-contrast palette twice and printed the two results as “light” and “dark”. The default palette — the one an app is in with nothing set — was never measured at all.

Since 1.6.0 the palette is an axis of its own, so the gate measures four combinations rather than two, and the two palettes are held to different bars. The high-contrast palette blocks: a violation there fails the build, and there are none. The default palette is measured and printed, not blocking — every run ends with a count, currently 384 pairs under their threshold, 78% of them from three tokens: --muted-foreground, --destructive-ink and --input. Lifting those means editing token values, which changes how every app looks, so it stays a design-system decision rather than a rider on a gate fix. Read the number as a debt with a ledger against it, not as a clean bill — and read any “light” figure written before 1.6.0 as the high-contrast palette's until it has been re-measured.

Two exceptions, corrected in both palettes

Two pairs in the approved design put text so close to its own background that no screen renders them. They do not wait for the user to switch palettes — the background stays as approved and only the text is darkened. Badge warning: 1.52:1 → 6.24:1 via --yellow-ink. Badge success: 2.82:1 → 5.88:1 via --green-ink.

What the high-contrast palette changes

TokenDefault paletteHigh contrast
--muted-foreground#7E8290 · 3.83:1#686C7A · 5.23:1
--input / --control-edge#EDEEF1 · 1.16:1#7E8290 · 3.83:1
--choice-edge#959597 · 2.99:1= --input · 3.83:1
--border#EDEEF1 · 1.16:1#A4A4A6 · 2.49:1
--control-on#E8B824 · 1.86:1#B48700 · 3.28:1
--primary-ink#E8B824 · 1.86:1#7A5800 · 6.51:1
--link-ink#B88A00 · 3.15:1#7A5800 · 6.51:1
--destructive#D64545 · white at 4.38:1#C93B3B · white at 5.03:1

Light-mode figures. Note that --border is raised to 2.49:1 rather than to the control-border step: it frames every card and every table row, and pulling it level with --input would erase the distinction between "you can operate this" and "this is a rule". Note also that --control-on becomes #B48700 and not gold-500 — gold-500 clears the threshold but reads as olive-brown, and a contrast mode must not change the brand hue.

…and what it changes again in dark

High contrast is a second axis, not a second theme, so it has its own dark branch: .dark[data-contrast="high"]. Roughly half the table above would be wrong if it were left to fall through — #A4A4A6 is a light grey, and on #16181D it reads as a glaring rule rather than a quiet one. So the dark branch restates those tokens at values that land on the same ratio from the other side.

TokenDefault palette · darkHigh contrast · dark
--muted-foreground#7E8290 · 4.64:1 on --card#888D9B · 5.35:1 on --card
--input / --control-edge#434A5A · 2.00:1 on --card#7E8290 · 4.64:1 on --card
--border / --sidebar-border#1E2128 · 1.10:1 on --card#50586B · 2.49:1 on --card
--control-on · --primary-ink= --primary #F5D060= --primary · 12.67:1
--link-ink / -hovergold-400 / gold-300gold-200 / gold-100
--teal → --teal-ink#1F8A8B#2DD4BF · 7.12:1 on its /15 tint
--green → --green-ink#2EA56A#4ADE80 · 7.44:1 on its /15 tint
--red → --red-ink#D64545#F87171 · 5.18:1 on its /15 tint
--yellow → --yellow-ink#F2C94C#FACC15 · 7.19:1 on its /20 tint

The last four rows are the ones the light branch has no equivalent for. In light, the accents are the same hex in both palettes; in dark they are lifted to a brighter step, and the ink steps ride along for free — dark declares --teal-ink: var(--teal) and the same for green, red and yellow, so raising the fill raises the text with it and no second value has to be kept in sync. That is the payoff for a rule that looks like redundancy in the light branch. Note also the specificity: [data-contrast="high"] and .dark both weigh (0,1,0), so the order of the blocks in styles.css is what makes this work at all.

Pairs that pass

Ratings are for normal-size text: AA is 4.5:1 and above, AAA is 7:1 and above. A pair between 4.5 and 7 clears AA and nothing more, however comfortable it looks.

ForegroundBackgroundRatioRating
ink #0E1116--background #FFFFFF18.91:1AAA
ash-50 #F7F7F8--background dark #0E111617.66:1AAA
ink #0E1116paper #FBF7EE17.69:1AAA
--navy-foreground #FFFFFFnavy #0F1E3D16.49:1AAA
--primary-foreground #0E1116--primary gold-200 #F5D060 (dark)12.67:1AAA
--warning-foreground #0E1116--warning #F2C94C11.92:1AAA
ink-soft #3A3F4A--background #FFFFFF10.55:1AAA
--primary-foreground #0E1116--primary gold-300 #E8B824 (light)10.19:1AAA
--red-ink #9A3537--background #FFFFFF7.15:1AAA
--info-ink #1A6668--background #FFFFFF6.68:1AA
--success-ink #216A48--background #FFFFFF6.52:1AA
--warning-ink #7A5800--background #FFFFFF6.51:1AA
--success-foreground #0E1116--success #2EA56A6.04:1AA
--success-ink #216A48green/15 tint5.60:1AA
--info-ink #1A6668teal/15 tint5.54:1AA
--info-foreground #0E1116--info #1F8A8B4.56:1AA

Tokens outside Tailwind — @comitor/ui/tokens

A fifth entry point ships these values as plain TypeScript objects, for the places where there is no Tailwind to compile a class: canvas rendering, PDF export, chart libraries driven by hex, and HTML email. It has no peer dependencies.

Read this precisely: @comitor/ui/tokens is not a mirror of the main entry, it is the main entry's token module. @comitor/ui does export * from './tokens.js', so /tokens is a strict subset of . — split into its own door only so code that wants the constants can skip React and Tailwind entirely. Importing semantic from either door gives you the identical object, not two copies that could drift. Use /tokens from a worker, a mail renderer or a script; use whichever is already imported everywhere else.

tsx
import { accentInk, chart, getAvatarToneColors, layout, semantic } from '@comitor/ui/tokens'

// Canvas, PDF export, chart libraries with no Tailwind, HTML email.
const series = chart.light[0]                     // '#E8B824'
const headerPx = layout.headerHeight              // 56
const errorText = semantic.dark.destructiveInk    // '#D64545'
const successText = accentInk.light.green         // '#216A48'

// Same tone table and same hash as getAvatarToneClasses() in '@comitor/ui',
// so one person gets one colour on the web and in email alike.
const { background, foreground } = getAvatarToneColors('Nguyen Duc Thanh', 'light')
ExportShape
gold, ash, brandNeutralFlat maps of the three raw scales.
accent, accentInk, accentForegroundThe three roles of navy / teal / red / green / yellow, each split into light and dark. accentForeground has no red, matching the CSS.
semanticsemantic.light and semantic.dark — the full shadcn set plus every control token.
highContrastOnly the tokens the high-contrast palette overrides. Spread it over semantic to get a complete palette.
statussuccess / warning / info / destructive with their -Foreground and -Ink steps.
chartchart.light and chart.dark — five hex values in default series order.
layoutheaderHeight 56, sidebarWidth 256, sidebarCollapsedWidth 56, radius 6, breakpoints, minTapTarget 44 — all px, all at the default font-size step.
typographyThe two font stacks as strings.
getAvatarToneColors, getAvatarToneIndexOpaque { background, foreground } hex for a name — same tone table and hash as getAvatarToneClasses() in @comitor/ui.

The rule that comes with it

Inside a Tailwind context, always use the class — never a hex read out of this module and dropped into an inline style. The class resolves a CSS variable at run time, so it follows light and dark and follows the palette; a hex is frozen at the moment you typed it. Two exports do not mirror anything on purpose: --elevation-* (box-shadow strings only mean something in CSS) and --app-accent* (owned by the app, not by the package).