Kanzo UI
The design of Kanzo UI

The colour document

Why a theme is a flat block of CSS with one mode, how a tint is written, what a section is, and what each would take to reverse.

Themes is the page for someone choosing or writing one. This page is why the layer is shaped the way it is, and what each of it would take to reverse.

A theme is one flat block of CSS, and one mode

A theme is a hand-written [data-theme="<name>"] block in packages/theme/themes/: the colours, the seven shape knobs and its own color-scheme. It carries one mode; light and dark are two themes, not two blocks of one document. The vocabulary names twenty-one colours a theme authors; every other colour name is a use of one of them, bridged once in tokens.css through @theme inline, never re-declared. Nothing derives a theme: there is no palette package and no reference tier of steps.

A tint is a percentage on the role, computed where it is used. bg-destructive/7 in a recipe, and color-mix(in oklab, var(--destructive) 7%, transparent) where a utility cannot reach — a style object, a CodeMirror theme, a canvas fill. Tailwind compiles the first to the second, so there is one mechanism with two spellings. There is no step name for a dilution: a name that can only ever be this role, thinner is a second spelling of the role.

Why a percentage is safe. A percentage lands on a different result in each mode when one document serves both, because the two sides of a ramp are not symmetric. With one mode per theme there is no second side for the same percentage to land on differently, which is daisyUI's arrangement too — two flat blocks under [data-theme=light] and [data-theme=dark], no shared document.

The trap, and it is not a compile error. :root { --sidebar-primary: var(--primary) } and the same line in @theme inline are not equivalent. A custom property inherits its computed value, so the first resolves once on <html> and a scoped <div data-theme="catppuccin-mocha"> inherits the already-substituted answer — Catppuccin's brand with the page's sidebar, silently. @theme inline puts the value in the utility, so it resolves on the element wearing the class. That is why the whole vocabulary is bridged and none of it re-declared, and why the radius scale hangs off --radius-field directly rather than an intermediate.

What it does not touch: the shape of a colour decision, only where it is written. --*-content is the on-fill ink and the status families keep Shark's asymmetry, where -foreground is a readable-on-the-page variant rather than text on the fill. The artefact is measured rather than the derivation: status.test.ts reads the shipped themes and measures five variants against every one of them, and themes.test.ts measures every fill/ink pair once resolved.

Every read resolves in every theme. A var(--x) anywhere in a published package or in docs/ must name something every theme declares, something the bridge chains, something the reading package sets, or something a runtime library provides. An undefined custom property fails silently — a fill turns black, a tint turns transparent — so this is a guard and not a convention.

What would reverse it: a tenant who cannot express their identity in twenty-one authored colours plus the knobs — a requirement for a colour that must be computed from another at runtime rather than written down. Authoring cost is not that evidence: a theme is the thing you paste.

Held by packages/ui/src/simples/status.test.ts, "has a corpus, and it is every shipped theme"; packages/ui/src/theme-tokens.test.ts, "resolves every var() in the corpus" and "names no deleted alpha step in a utility"; packages/ui/src/lib/token-color.test.ts, "declares a full set of slots, or none at all, in every theme"; packages/theme/scripts/gen-theme.mjs.

Themes ship as families, and a user picks a day one and a night one

A theme is one mode, so what a user chooses is a pair: the theme worn by day and the theme worn by night, plus which side is worn — GitHub's Appearance model, and the theme cards in Preferences are its layout. The library ships four families (kanzo, nord, catppuccin, lofi), each exactly one light and one dark theme, named in the file header; gen-theme.mjs refuses a family that is not a pair. Twenty-nine single themes, half of them without a partner, made the night choice a guess.

The provider always writes the resolved theme to data-theme, including the tenant's default: the CSS default binding (:root:not([data-theme])) can name one family, and a tenant whose default is its own family must not depend on it.

What would reverse it: a product whose users want a theme that has no other side — a high-contrast or print theme worn regardless of the OS. Then a family of one is a real case and the pair rule in the generator becomes a warning.

Held by packages/theme/src/themes.test.ts, "ships families of exactly one light and one dark theme, the default family first"; packages/ui/src/theme/theme-script.test.ts, "writes the resolved theme: the one chosen for the side, or the default pair's".

A family's night side carries its brand at the day side's weight

A dark theme's --primary and --chart-1 — the brand fill and the series a family puts its brand on — are no more chromatic, in OKLCH, than its light partner's plus 0.05. A dark ground makes a saturated colour louder rather than quieter, so the side that is already louder may not also be the more saturated one.

The measurement (2026-10-13, packages/theme/themes/): kanzo-dark carried the neon #1ae973 as its brand fill at chroma 0.218 against kanzo's deep green #004011 at 0.097, and a lime #64c600 (0.218, at hue 136 rather than the brand's 151) as its first series against #0b7a3b at 0.133. Every other family's night side sat at or below its day side: nord-dark 0.062 against 0.073, catppuccin-mocha 0.119 against 0.250. kanzo-dark now takes #5cb572 (0.130) as its fill and ring and #52b770 (0.140, hue 151) as its first series; the neon stays as --success, which is where the day side keeps it too. The allowance is there because a fill at the day side's chroma on a near-black ground reads greyer than the same fill on white.

What would reverse it: a family whose identity is a saturated colour at night — a brand that is neon on its own dark site and reads as itself only that way. Then that family declares its night side louder in its header, and the guard learns the exception by name.

Held by packages/theme/src/themes.test.ts, "carries a family's brand at night at no more chroma than by day, give or take 0.05".

The obvious ink is computed in the form, not in the page

@kanzo-tech/theme exports inkFor, which answers you picked this fill, what text goes on it? — the rule daisyUI's generator uses, recovered from its output, with WCAG AA as a floor rather than a hope. It runs in the theme generator while somebody authors, on one colour at a time, and what it returns is written into the document as a literal hex. Themes stay flat blocks: nothing computes a colour in a page painting one, and tokens.css gains no color-mix.

With no derivation, a value nobody computes is a value somebody types, and a typed copy drifts: a hand-typed seed in the studio disagreed with kanzo.css on five tokens without anybody noticing.

What the measurement was. daisyUI derives nothing at runtime — zero oklch(from …) and zero color-mix in 5.7.20, and their primary-content variable read bare 136 times with no fallback. Its 35 themes ship all 28 values flat. But they were generated, and of 280 fill/ink pairs 156 carry the fill's hue to four decimals, from which the rule reads off: carry the hue, cut the chroma to a fifth, pull the lightness most of the way to the nearer end.

Two departures, both measured rather than preferred. The branch is chosen by which one actually contrasts more, not by daisyUI's lightness threshold — the band where the two disagree is exactly where the threshold picks the unreadable one. And AA is enforced: over 9,178 in-gamut fills the plain formula falls below 4.5:1 on 11.3% and daisyUI's own shipped pairs fall below on 14.3% (worst 3.04:1), where inkFor returns nothing below 4.50:1. Against the graphs it has to answer to, daisyUI's own pairs come back at ΔE 0.60 median, 21 of 23 within 8 — and the two that miss are missed identically by daisyUI's own formula, so they are pairs a person adjusted after generating.

What this does not decide. Anything about the categorical channel: --chart-1..8 are not a function of --primary, and measuring says so plainly — --chart-6 takes twelve distinct values across the sets themes have authored, which a rotation of one brand colour cannot produce, and a theme whose brand is achromatic has no hue to rotate at all.

What would reverse it: a fill whose ink is not a function of it — a role where two themes with the same fill want different inks for a reason other than taste. Authoring convenience is not evidence in the other direction either: if the rule stops being right, the proposal is what changes, never the fact that the document holds a literal.

Held by packages/theme/src/ink.test.ts, "never returns an ink below AA" and "reproduces daisyUI, and where it does not, the formula does not either".

The categorical default is one set, for every theme

tokens.css declares eight categorical colours on :root, so a theme gets a working chart channel without authoring anything. An authored [data-theme] block overrides them slot by slot on the element it sits on. A theme that means to decline the channel says so with --chart-capacity: 0; a theme that simply has no set of its own says nothing, because those are different facts and need two spellings.

Without the default, a theme with no categorical set has charts that paint nothing, and there is no colour in a theme a categorical set can be derived from.

Every shipped theme authors its own eight, and each is judged on its own theme: every slot at 3:1 against that theme's background (CONTRAST_PAIRS), and clear of that theme's status fills. Failing a set against surfaces it never claimed to sit on would be marking it down for a promise it did not make.

How many series a chart may draw is not this rule's question. These floors are over adjacent pairs, which is what a stack, a bar group and a line chart put side by side. Over all twenty-eight pairs no eight-colour set clears them — measured while searching, and the reference systems say the same — so a form that shows every pair at once (scatter, bubble) carries a series cap. That belongs to the chart, not to the theme.

What would reverse it: a set that reads as a state on a client's theme — a series in a colour close enough to that tenant's --success or --destructive that a reader takes a category for a status. The floor is measured per theme, on the set it resolves to; a tenant authors their own.

Held by packages/theme/src/categorical.test.ts, "stays clear of each theme's own status fills, so a series cannot read as a state" and "lets a theme decline the channel, and only by saying so"; packages/ui/src/lib/token-color.ts, categoricalCapacity.

Monochrome is a palette, not a look

A monochrome theme is one more theme, chosen in the theme selector like any other — none ships today — and the graph keeps carrying identity because the host binds symbol alongside fill, so the same binding survives a colourful document and a monochrome one. Colour belongs to the colour layer, and the user already has one control for choosing it.

A document that declines the categorical channel must declare that it declines it, distinguishably from a document whose set is merely missing. --chart-capacity: 0 is that declaration, and it is read as zero — never clamped back up to the slot count — because a zero-capacity document renders every series as Other, and a deliberate monochrome document and a broken one are otherwise indistinguishable.

No component learns which document is applied. The graph binds two channels and reads the scale; that is true on every document and this one is not a branch.

What would reverse it: a categorical mark with no non-colour fallback shipping on this document — a stacked bar, a multi-series line, a pie. If readers lose the encoding there, monochrome is not a document-level state and belongs to whatever draws a graph.

Held by packages/ui/src/lib/token-color.test.ts, "honours a declared zero, because a document may decline the channel"; packages/theme/src/categorical.test.ts, "lets a theme decline the channel, and only by saying so".

--accent is a surface, not a third brand colour

--accent is the quiet ground a row wears when it is hovered or selected, in every shipped theme. A theme ported from daisyUI does not take daisyUI's accent, which is that system's third brand colour; it takes a step of the base scale. --accent-foreground is authored by nobody, falling through the bridge to --foreground. One word names two roles in the two systems, and the recipes that read it are Shark's.

--secondary looks like the same bug and is not. lofi's secondary is a dark fill, but every bg-secondary call site pairs it with text-secondary-foreground, and that ink is authored and passes. The two systems disagree about what a secondary button looks like — grey here, coloured there — and that is a difference of taste inside one agreed role, not two roles under one name.

What would reverse it: a theme that needs a third brand fill with a name of its own — a component that renders one, or a client whose identity is three colours and cannot say so. Nothing renders one today.

Held by packages/theme/src/themes.test.ts, "keeps every pair at its WCAG floor once resolved — text at AA, marks and boundaries at 3:1" — the pair that carries this is --accent against --muted-foreground, listed in packages/theme/src/contrast.ts.

An invalid boundary needs no dark branch

An invalid control's border and ring read --destructive on both sides; there is no dark: override repainting them with --destructive-foreground. The text- half of the same override stays. This is a deliberate divergence from Shark UI, which writes all three.

The measurement. --destructive against the theme's own --background, over the dark themes in packages/theme/themes/ (measured 2026-10-01): 3.74:1 at worst, nord-dark. WCAG 1.4.11 asks 3:1 of "the visual information required to identify a control or its state", so the border and the ring clear it on the dark side with the base token alone. The override bought no contrast; it bought a different hue in one mode only, at the cost of every one of those recipes carrying a mode branch the token layer exists to make unnecessary.

The text- half is not the same question, and the same measurement is why it stays. Text owes AA's 4.5 and 3.74 misses it. So dark:data-invalid:text-destructive-foreground is doing real work on every one of those recipes. A measurement licenses exactly the divergence it measures, and here it splits one Shark line into a part that was earning its place and a part that was not.

Focus rings are a separate case, and they look identical in a grep. A focus ring answers a different measurement — a diluted one measured 1.29:1, which is why --ring is never diluted. invalid and focus-visible are separate cases wearing one spelling.

shark-parity.test.ts does not see this and cannot: it compares the export surface against Shark's registry, and a recipe's internals are outside its corpus. That is why the divergence is declared here rather than in shark-parity.divergences.ts.

What would reverse it: a dark theme whose --destructive falls below 3:1 against its own --background. None does. The light side is a different question the override never answered — it was dark: only — and lofi's pastel fill does fall below 3:1 there; that is a property of that theme, not an argument for a mode branch.

Held by packages/ui/src/simples/input.tsx, inputVariants, which carries the measurement and is the site the other recipes point at.

A section brings measurable obligations, or it is a preference table

An appearance vocabulary is a section only if it publishes obligations returning at least one measurable claim — a number, the bar it must clear, and what the bar came from. A section is admitted for HAVING a bar, never for passing it. On that test there are three: colour, graph geometry, and density. Radius and the faces are not preferences at all: they are the theme's (see Preference or theme).

The clause that will decide future cases, stated on its own. Density's target-size obligation is red today — the smallest control measures 21 CSS px at compact against WCAG 2.5.8's 24 — and that is precisely why density is a section. If passing were the criterion, fixing a defect would demote a section to a preference and introducing one would promote it, which is absurd. A bar you can break is what a section is. An obligation may also be named and ungradeable, which is a third state and not a loophole: link-fade in the graph and x-height in density both ship measured: null with what would close them, because a bar nobody established cannot be cleared, and reporting it green is how a guard comes to test a corpus of zero.

Two names for a decision, never for a value. The rule that retired seventeen tokens is easy to misquote as two tokens may not resolve to the same value, and that version is measurably wrong: over @atlaskit/tokens@16.7.0 there are 466 active colour tokens, 130 distinct values, and 434 of them (93%) share a value with another token — #1868db alone is seventeen tokens. The naive rule would delete roughly 72% of a healthy shipped API. What was actually applied is narrower: the seventeen were retired because each was a second name for one decision. Two names for one decision is the defect; two names for one value is often correct, and the eight --sidebar-* names are the local proof — no theme authors them, the bridge resolves each to the token it repeats, and they are kept because they are a contract the reference pastes in verbatim.

The registry, and the two properties it is built on. Resolution is Neovim's hierarchical fallback: a dotted name falls back to its parent, --graph-point-size-min → --point-size-min → the manifest's default. What earns it over everything else surveyed is that contributing is using a name, not declaring one in the core — the core never learns a section exists, so the one-way door stays shut structurally rather than by discipline. Validation is the half Neovim does not have: there a typo degrades silently to the parent, while here the manifest declares its tokens, so resolution goes by fallback and validation goes against the declaration. The survey found nobody doing both — VS Code's contributes.colors and Emacs contribute without validating the namespace; Vanilla Extract validates completely and therefore cannot have optional sections; StyleX retracted that shape. It is unmapped ground, and sections.test.ts states its blind spots rather than implying it has none.

A default is a CSS value over the tenant's roles, and that is what makes white-label flow. VS Code spells this as imperative transforms (darken, transparent, oneOf); here a section token's default is a plain value the browser resolves — --graph-grid defaults to var(--input). A bank changes its theme and the grid moves with it, without the core knowing what a graph is and without anyone copying a hex. A token that could only ever repeat a role, or dilute one, is not declared at all: the graph's marquee is color-mix(in oklab, var(--primary) 17%, transparent) where it is drawn. Minting a role in the core's vocabulary for one consumer is what this refuses; a section token lives in the owner's namespace and costs nothing to whoever never installs the package.

Chart and editor are not sections. The eight categorical slots with their capacity, and the eight syntax hues, are parts of colour rather than peers of it — which is why the count is three rather than five.

What the rule found on its first run, and it is the argument for having it: the graph's own floor was justified by a false argument. shape-floor was written as shapes collapse below four pixels; Smart & Szafir (CHI 2019, doi:10.1145/3290605.3300899) measured 16 shapes across six sizes and found discrimination robust to size — significant variation only at 6 px, and 4.5 accuracy points. The conclusion survived and the reason did not: what the floor protects is the size and luminance channels from shape's interference, which is Ink's exact case because it encodes identity as shape and degree as size. Writing the obligation down is what surfaced it, one step before it became a machine-checked claim.

What would reverse it: a section turning out to have no gradeable bar after all, which would make it a preference table and take the mechanism back below its call-site threshold.

Held by packages/graph/src/render/obligations.test.ts, "grades at least one, which is what admits it as a section"; packages/theme/src/obligations.test.ts, "fails exactly the one it is known to fail, and no other"; packages/theme/src/sections.test.ts, "keeps a section whose package is not installed".

Density's legibility floor is a section's bar, and it is currently red

Density is a section because it has a gradeable bar: WCAG 2.5.8 Target Size (Minimum). Its obligations ship as a Report[] in @kanzo-tech/theme, in the same shape the graph's use. The target-size bar fails today and is pinned by id rather than hidden. The x-height bar is named and ungradeable, with what would close it. No height and no density step is changed: the measurement says the fix does not go where it first appeared to.

themes.css sets the root font-size per data-font-size as a percentage of the browser's — compact 87.5%, default 100%, comfortable 112.5%, so 14, 16 and 18 px at the browser default — and every size in the library is rem, so compact scales everything by 0.875.

thingdefault (16 px root)compact (14 px root)bar
Button size="xs" (h-6)24.0 px21.0 pxWCAG 2.5.8 AA: 24×24
Button size="sm" (h-7)28.0 px24.5 pxsame
Input size="sm" (h-7)28.0 px24.5 pxsame
--kanzo-font-size-xs (0.625rem)10.0 px8.75 pxnone in WCAG, and no house bar either

The smallest button is exactly at the bar at default density, which is the tell that nobody chose 24 — it fell out of h-6 and the default root, and the density axis then moved it without anything noticing.

Probed live at compact across three showcases, deduplicating nested targets (a checkbox is a 14×14 <label> wrapping a 1×1 sr-only <input>; counting both made every checkbox fail against itself, which was the first draft of this probe and was wrong):

showcasetargetsunder 24×24saved by the 2.5.8 spacing exceptiongenuinely failing
app-shell45880
metadata-form (compact)26440
workspace3215411

No size="xs" button failed anywhere. Every undersized button was saved by the spacing exception, because the library's own gaps are wider than 24 px between target centres. The eleven real failures are a hand-rolled list row — flex w-full items-center gap-2 rounded-sm px-1 py-0.5 text-xs, measuring 187.3×17.5 px with centres 19.3 px apart.

Whose bar is it, and the correction the measurement forced. The reading this started from was: the base height belongs to ui, the multiplier to density, WCAG measures the product, so the obligation sits with whoever can break it — and what moved 24 to 21 was density. That is half right, and the failing half matters: those eleven rows fail at the default density too (184.3×20.0 px, centres 22.0 apart) and clear only at comfortable. So density did not move a passing target to failing; it made an already-failing one worse, and at the loosest setting it fixes it. The correct division: density is a multiplier on a bar both layers can break, and here the component broke it first. The obligation is stated in @kanzo-tech/theme because that is where the multiplier lives and where the axis can be graded — not because the fix belongs there.

And that is the finding worth more than raising h-6: the system's own xs variant is fine in practice, and what actually fails is hand-rolled markup with a py-0.5. Raising the variant would have fixed nothing measured here.

What would close it, in the order the bars are worth having: a target-size obligation over every interactive recipe at every density, checkable in jsdom against the compiled stylesheet, which is the one with a real external standard behind it; a minimum type size, which needs a house bar with a reason because WCAG sets none; or a floor on the axis itself, the cheapest of the three, declaring a minimum root font-size the density axis may emit.

What would reverse it: the target-size bar turning out not to be density's to grade — the measurement above already moved it halfway.

Held by packages/theme/src/obligations.test.ts, "fails exactly the one it is known to fail, and no other"; packages/theme/src/obligations.ts, OBLIGATIONS.

A preference is contributed the way a token is

A package that owns a user-facing choice contributes it as a preference section — a namespace, declared options, a default — exactly as it already contributes tokens: stored opaquely by the core, validated against the manifest its owner ships, preserved when the package is absent, and drawn by one renderer on any surface. A tenant's document may pin or withhold a section, which is what makes a white-labelled product one product rather than a fork. Resolution is one chain — pinned, stored, the tenant's starting point, the declaration's default — and the host registers the manifests, so the core still names no optional package. The core's own axes go through all of it, under the namespace theme.

The appearance document already extends by namespace and the preference model did not, so every optional package with a user-facing choice had to invent a settings surface beside the panel.

Four things that were settled while building it, because each was a live alternative:

  • The storage layout is one key, sections, keyed by namespace then by preference, values always strings. What settled it was the read-time whitelist: riding on a key the defaults already have is what lets an unrecognised namespace survive a write by a host that dropped the package.
  • One manifest carries both halves. Two exports would let a namespace and a version drift apart while describing the same section, and the namespace is the whole contract.
  • «Rendered by the one panel» was wrong, and it was the sentence that mattered. A section draws wherever it is mounted — the panel, a settings page, a dock inside a canvas — and what a surface renders it with is one control per kind. A mechanism whose only surface was the panel would have failed the first consumer that had a canvas.
  • The core's preferences stay the core's. Appearance, the theme per side and density are not re-expressed as a contributed section; a mechanism that swallowed its own host's table would be generalising in the wrong direction. They are drawn by the same PreferencesSections, as the namespace theme. Colour is not contributed by anyone either, because a document is a stylesheet and the attribute selects between compiled blocks.

The section rule above still decides what may be a section; this says how a section's preferences reach the panel, not what earns one. Simulation coefficients are still not appearance, and a panel that accepted them would be the drawer that rule exists to prevent.

What would reverse it: no second contributor appearing. If the graph's look is the only user choice an optional package ever has, this is a mechanism over a single call site and children on the panel is the whole answer.

Held by packages/theme/src/sections.test.ts, "keeps a section whose package is not installed"; packages/theme/src/index.test.ts, "every axis default matches DEFAULT_PREFS, generated or not"; packages/ui/src/theme/KanzoThemeProvider.tsx, PREF_KEYS.

The declaration ships with the library

The core's axes are declared as generated data — kind, options, labels, default, attribute, doc — emitted into theme-data.json and carried by the root barrel for every consumer of it. The barrel's size budget is rebaselined to the measurement rather than the declaration being trimmed to the old one. The resolution chain runs in a browser, so what it resolves against cannot stay at build time.

The working. Four gauges, brotlied, before and after:

gaugebeforeafter
root barrel (JS)41.54 kB42.12 kB
analytics subpath (JS)66.22 kB66.22 kB
one component (tree-shaken)1.08 kB1.08 kB
stylesheet24.16 kB24.16 kB

The whole cost is +0.58 kB on one gauge, and the generated JSON is most of it: minified and brotlied on its own, theme-data.json goes 302 B → 648 B. Three gauges did not move, and the one that matters to a consumer is among them — Button still resolves to 1.08 kB and three of our modules. Nobody who imports a component pays for this. Deleted in the same commits, and not counted as an offset because it is smaller than the noise: four hand-typed copies of tables that were already being shipped.

The case that does not fit. One axis carries no option list at all: themeByAppearance declares { from: "themes" }, because its values are themes a client authored after this package was built. That is what keeps this a fixed cost — a tenant publishing forty themes adds forty entries to the document they already ship, and not one byte here.

What it does not touch: the pre-hydration script, which is generated per host and carries its own copy of the same rows, because it runs before any module is evaluated.

What would reverse it: the tree-shaken consumer gauge starting to carry it, or the declaration growing with what a host installs rather than with what the library ships — either turns a fixed cost paid by the library into a variable one paid by a consumer.

Held by packages/ui/package.json, gauges; packages/theme/src/index.test.ts, "offers exactly the values it generated, so no list is typed twice" and "names a SOURCE for the axis whose options a tenant writes".

On this page