Kanzo UI
The design of Kanzo UI

The colour document

Why a theme is a flat block of CSS with one mode, what a section is, and the five arguments the refoundation retired along with the derivation that made them.

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/ — about fifty-five declarations: twenty-one authored colours, the shape knobs, the font stacks and its own color-scheme. It carries one mode; light and dark are two themes, not two blocks of one document. Everything else in the colour vocabulary is a use of one of the twenty-one, bridged once in tokens.css through @theme inline, never re-declared.

@kanzo-tech/palette and the derivation it held are deleted, with no alias and no migration path. The derivation published a vocabulary the library did not use, and a two-mode document was what forced every mechanism built on top of it to be two of something.

What the measurement was. The reference tier published 144 step utilities. Barrelled across packages/{ui,ai,graph}/src — 160 files — components named 18 of them, and all eighteen were alpha steps; not one solid step appeared anywhere. Of the 191 colour utilities the sheet declared, 126 were used by nothing. The eighteen were tints: bg-destructive-a3 and its neighbours. bg-destructive/7 compiles to the same color-mix(… transparent), so what eight thousand lines of ramp existed to name, CSS now computes at the point of use.

The clause that makes the rest of it work. A guard used to ban bg-token/NN on a measured, correct argument: a percentage lands on a different step in each mode/4 is a3 in light and a2 in dark — because the dark ramp is deliberately fatter at the bottom. Diluting with a percentage is the central mechanism of the reference this follows, so the two look irreconcilable. They are not, because the objection is conditional: it only bites when one document serves two modes. daisyUI's does not — verified in its published CSS, two flat blocks under [data-theme=light] and [data-theme=dark], no shared document. Adopt one-mode themes and the premise dissolves rather than being overruled. That is why the ban was retired and the ninety-four dilutions already shipping went from forbidden to correct with no edit.

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="dracula"> inherits the already-substituted answer — Dracula'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. Measured live: three levels of nesting resolve correctly.

What it does not touch: the shape of a colour decision, only where it is written. --*-content is still the on-fill ink and the status families still keep Shark's asymmetry, where -foreground is a readable-on-the-page variant rather than text on the fill. It also does not restore the contrast measurement the derivation used to make — given up knowingly — but the artefact is still measured: status.test.ts reads the shipped themes and measures five variants against every one of them, which is a check over output rather than a guarantee at authoring time.

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.

Five rules died with the derivation, and none of them is folded in here, because there is nothing left for them to govern. The colour derivation is authoring-time described a package that no longer exists. A role earns its name, or it is a step wearing one retired seventeen level-names from a role table that has since been cut to twenty-one authored colours. A categorical set may use the palette's own colours and a palette is chosen per appearance both described the search and the two-mode document. Prose that is hashed is data described a digest that shipped inside a derived document. Each was correct when the layer had a derivation in it; each is answered by the block above.

Held by packages/ui/src/simples/status.test.ts, "has a corpus, and it is every shipped theme"; 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.

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

Cutting the derivation left nobody computing the boring half, and a value nobody computes is a value somebody types — fifty-four of them per theme, which is how the studio's seed came to disagree 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 corpora 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 sixteen themes, 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 keeps saying so with --chart-capacity: 0; a theme that simply has no set of its own stops saying it, because those are different facts and had come to share one spelling.

A theme with no categorical set had charts that painted nothing, and there is no colour in a theme a categorical set can be derived from.

The sixteen authored sets are not re-judged here: they were derived per document against that document's own background, and failing them against two surfaces they never claimed to sit on would be marking them down for a promise they 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 here is measured against the themes we ship; a tenant authors their own.

Held by packages/theme/src/categorical.test.ts, "stays clear of every status fill the catalogue publishes, 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

Monochrome ships as one more theme, chosen in the theme selector like any other, and the graph keeps carrying identity because the host binds symbol alongside fill — the same binding survives a colourful document and this 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. Four things follow from that, and each was a live defect:

  • A hue-less seed does not produce an achromatic set. Seeding grey gives the default colours, not one ink — manufacturing a hue for a grey brand is what this layer refuses everywhere else.
  • A declared zero capacity used to read as "not declared", because a clamp pushed a non-positive declaration back up to the full slot count — which is how a document that never said anything came out.
  • A zero-capacity sheet is otherwise treated as corruption: such a sheet renders every series as Other and looks like a palette rather than a breakage. A deliberate monochrome document and a broken one are byte-identical unless one of them declares itself.
  • separation had to become nullable. An empty pair list computes to Infinity, which reads as a perfect score, and zero reads as two categories nobody can tell apart. Neither is true, so the field is null per mode for a declined set. leading stays a number, because no leading slots is a true count.

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/themes/monochrome.css, --chart-capacity.

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

--accent is the quiet ground a row wears when it is hovered or selected, in all twenty-nine themes. An imported theme does not take daisyUI's accent, which is that system's third brand colour; it derives one from their base scale instead — base-300 mixed 60% toward base-200. --accent-foreground goes with it and 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. Ten of the thirteen carry a saturated daisyUI secondary, but all six bg-secondary call sites pair 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. Measured before deciding, and left alone.

Whether an imported theme is still that theme: it is. No colour was corrected, which is the importer's standing rule. A token was mapped from a different source, which is what the other four mapping decisions in that file already do.

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, which is the whole reason the collision was survivable for as long as it was.

Held by packages/theme/src/themes.test.ts, "keeps every pair at AA once resolved" — the pair that carries this is --accent against --muted-foreground; packages/theme/scripts/import-daisy.mjs, blend.

An invalid boundary needs no dark branch

The dark: overrides that repainted an invalid control's border and ring with --destructive-foreground are removed from twelve recipes. The text- half of the same overrides stays. This is a deliberate divergence from Shark UI, which writes all three.

The measurement. Step 9 of the destructive ramp against that document's own page, over all six compiled documents: 4.15:1 in dark, 4.56–4.57 in light. 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 in both modes with the base token alone. The override bought no contrast; it bought a different hue for its own sake, 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 dark's 4.15 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 were not touched, and they look identical in a grep. A focus ring answers a different measurement — the diluted one measured 1.29:1, which is what banned diluting --ring at all. Two of them were removed in the first pass and restored; the lesson is that 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 the blind spot this covers, and it is why the divergence is declared here rather than in shark-parity.divergences.ts.

What would reverse it: a document whose destructive fill stops clearing WCAG 1.4.11 against its own dark page. None does, and a client's destructive seed is not overridable — the four status families are Kanzo's.

Held by packages/ui/src/simples/input.tsx, inputVariants, which carries the measurement and is the site the other eleven 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, font and mono font remain a preference table.

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-* roles are the local proof — byte-identical to their counterparts in every shipped document, and 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 may be derived, and that is what makes white-label flow. VS Code spells this as imperative transforms (darken, transparent, oneOf); we already had the better form. A section token's default is a binding--graph-marquee is (brand, alpha 5) — declarative against the tenant's ramps. A bank changes its palette and the marquee moves with it, without the core knowing what a graph is and without anyone copying a hex. This is not the thing that was refused: minting a role in the core's vocabulary for one consumer is how the seventeen happened, whereas 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 seven 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/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 — compact 14 px, default 16, comfortable 18 — 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 rowflex 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 four generated axes stay the core's. Radius, font, mono font and density are not re-expressed as sections; a mechanism that swallowed its own host's table would be generalising in the wrong direction. 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. Two axes carry no option list at all: identity and paletteByAppearance declare { from: "identities" } / { from: "palettes" }, because their values are brands a client authored after this package was built. That is what keeps this a fixed cost — a tenant publishing forty brands 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, size-limit; 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