Kanzo UI
The design of Kanzo UI

Preference or theme

One rule decides whether a value is the person's or the brand's. The declaration lists only preferences, so a value the theme owns never gets a control.

Accepted on 2026-10-02. Corner radius and typography belong to the theme. Appearance, the theme worn on each side and density are the person's. Everything that stores, resolves, paints or draws a preference derives from one declaration, and that declaration holds nothing a theme owns.

The defect this removed

The library used to export a control for every value it could emit. Each host then decided the split for itself, by choosing which controls to mount. One host's preferences page mounted the radius slider next to the theme picker. Every shipped theme declares --radius-box, --radius-field and --radius-selector, and the slider overrode them without any error:

  • [data-theme] and [data-radius] sat on <html> at the same specificity, and themes.css emitted the radius blocks after the theme imports. So the person's step won over the brand's radius.
  • The slider's md step wrote no attribute, so at md the theme's own three radii showed. Each of the other four steps derived all three radii from one number. One step meant "the brand's" and four meant "ignore the brand".
  • The faces had the same shape. They had not broken yet only because no theme named a face.

None of this was a bug in the slider. Nothing said which values a person may change.

The rule: the person's reading, or the product's identity

A value is a preference when it answers how this person reads. It is right or wrong because of the person: their eyes, their screen, the light in the room. It would be the same value in any product they used.

A value belongs to the theme when it answers what this product looks like. It is right or wrong because of the brand, and it is the same for every person looking at that tenant's product.

Two tests, asked in this order:

  1. Would two people in the same tenant want different values, for reasons about themselves? A dim room, low vision and a small laptop are reasons about oneself. "I like rounder buttons" is taste, and taste is the brand's job.
  2. Does the platform already ask this question? If the OS or the browser carries the setting (prefers-reduced-motion, prefers-contrast, forced-colors, the browser's font size), the library honours it and adds no axis of its own.

A value that fails the first test belongs to the theme. It is authored in themes/*.css or by the theme generator, and nowhere else. A value that passes the first test and is caught by the second belongs to the platform. Only a value that passes the first test and gets past the second enters the declaration.

What would reverse it. A value that both tests misfile: people need it to differ for reasons about themselves, and yet changing it changes what the brand is. A legibility typeface is the only candidate in sight. It is answered below without reversing the rule.

Held by packages/theme/src/index.test.ts, "declares no value a theme owns, so nothing can override one".

Every axis, judged

axisverdictreason
appearancepreferenceAmbient light and eyes. Its starting point is the OS's, see below.
themeByAppearancepreference, among the tenant's themesThe values are identities the tenant published. The choice among them is the person's. The tenant controls the set (themes) and may withdraw the choice.
densitypreference, and never the tenant'sScreen size and eyesight. It is the one axis with a measured bar, WCAG 2.5.8 in OBLIGATIONS.
radiusthemeIt fails the first test. Every theme authors all three radii, and the generator edits them.
sans, heading and mono facesthemeThey fail the first test. A face is the most recognisable part of a brand after its colour.
shape knobs (--size-field, --stroke, --relief, --noise)themeThey never were preferences.
chart categorical setthemeIt is part of colour, see colour.
motionplatformprefers-reduced-motion already asks. The animated recipes honour it with motion-reduce:.
contrastplatform, plus the theme's floorEvery theme clears AA (auditContrast). Wanting more is the platform's question, see below.
contributed sectionsthe same rule, applied laterThe graph's section is audited in a separate change. See the last section.

Typography is the brand's, and legibility would be a different axis

Picking a typeface is taste. A legibility face, such as Atkinson Hyperlegible or a face designed for dyslexia, is a reason about oneself, so it passes the first test. It is not the font picker coming back. It would be one preference with its own bar, which the library could grade the way it grades density's obligation. Until someone asks for one, the platform's answer stands: the browser's font settings and user stylesheets.

What would reverse it: a product whose users need a legibility face that the platform cannot give them. That adds one axis with one measured option. It does not bring back a list of faces.

Motion and contrast: honour the query, add no axis

Motion. The animated recipes carry motion-reduce:transition-none! or motion-reduce:animate-none! (tabs, popover, slider, steps, tour, listbox, tree view, input group, the preferences panel). That is the whole policy: honour prefers-reduced-motion wherever something moves. A recipe that animates without the variant is a defect to fix, not a reason for a switch.

Contrast. Every theme already clears WCAG AA, so "enough contrast" is the theme's obligation. Wanting more than AA is the platform's question, prefers-contrast: more. The provider does not switch themes automatically when that query matches. A tenant that cares publishes a high-contrast family, and the person picks it like any other theme. forced-colors: active is a matter of correctness: focus rings, borders and selected states must survive system colours. It needs a recipe audit, not a new token.

What would reverse it: a person who wants reduced motion or more contrast in this product only, and not across their OS. No such request exists. GitHub ships high-contrast themes rather than a contrast switch. Automatic selection on prefers-contrast would be reopened by a tenant that ships a high-contrast family and finds its users cannot find it in the picker.

Held by nothing. No guard asserts that every animated recipe carries motion-reduce:.

Appearance starts at the OS, and a stored pick wins

While nothing is stored, the side is the OS's prefers-color-scheme. Once the person picks a side, the pick is stored and wins, which is GitHub's behaviour. The OS stands in for the declaration's default light. It does not stand in for anything above that default. The chain is: what the tenant pinned, what the person stored, the tenant's starting side, then the OS. A tenant that sets a starting side has said something about its product, and that outranks a platform default. There is still no "system" value. The OS is never stored, so a person who never chooses follows their OS on every visit.

themeScript asks the same query before paint. The provider asks it in a layout effect, at the same moment it reads storage, so the server render and the hydrating client both start from light, and the page is corrected before it is painted.

What would reverse it: a tenant whose users complain that the product changes side with their OS. That is the case for an explicit "follow system" choice, a third stored value, which this deliberately does not add.

Held by packages/ui/src/theme/KanzoThemeProvider.test.tsx, "starts at the OS's side while nothing is stored" and "lets a stored pick win over the OS"; packages/ui/src/theme/theme-script.test.ts, which diffs both sides on a light and a dark OS.

Density is always the person's

A tenant's policy may move where density starts, with policy.theme.density.default. It may not pin density or withhold it. The declaration says so with one field, personal: true, and the resolution chain ignores pinned and hidden for any preference that carries it. The policy object stays valid and is not refused, so one policy works across every declaration. themeScript drops the same two fields when it serialises the policy.

Density decides how big everything is. A pinned density is a client deciding somebody's eyesight. The pin would also land on the axis whose WCAG 2.5.8 bar already fails at compact.

What would reverse it: a product that is not read by people choosing for themselves, such as a kiosk or a wall display. There the tenant is the only reader with a say, and personal would come off the declaration for that product. A per-axis switch in the policy would be the wrong way to do it.

Held by packages/theme/src/sections.test.ts, "never lets a policy pin or withhold a personal preference, only start it"; packages/ui/src/theme/KanzoThemeProvider.test.tsx, "never pins density: it is the person's, whatever the tenant says".

Density multiplies the browser's size

The steps are 87.5%, 100% (no attribute) and 112.5% of the browser's font size, not pixel values. The pixel steps replaced the browser's size, which is the platform's own reading preference. A person whose browser was set to 20px and who picked compact got 14px. Now they get 17.5px. This is the second test applied to an axis that passes the first. The density obligation is graded at the browser default of 16px, so its figures did not move.

What would reverse it: a measurement that a layout held at the pixel steps and breaks at the relative ones. Nothing suggests one, because every size in the system is rem.

Held by packages/theme/src/index.test.ts, "keeps density the person's: a policy may start it and may not pin or withhold it".

One declaration, and it lists only preferences

CORE_PREFS is generated by packages/theme/scripts/gen-theme.mjs. It holds three entries: appearance, themeByAppearance and density. Everything else is a projection of it:

consumerderives
ThemePrefs / DEFAULT_PREFSthe keys
AXESthe rows that write an attribute: data-theme and data-font-size
themes.cssthe catalogue's imports and the density steps, and nothing else
KanzoThemeProvider storagePREF_KEYS, the read-time whitelist, which drops a stored radius, font or monoFont with no migration
policy.themethe declared keys, with personal honoured
themeScriptthe same rows
PreferencesSectionsevery offered preference

The theme owns radius and the faces, and they are authored in themes/*.css: each theme declares its three radii and may declare --font-sans, --font-heading and --font-mono. The theme generator edits the radii. It has no face field yet, so a face is written into the generated file by hand. tokens.css keeps Geist and Geist Mono as the stacks a theme that names none falls back to. A theme ships no font files: the host loads the faces its published themes name.

What would reverse it: the first tenant who wants their face set in the generator rather than in the file. That tenant is the second call site a face field needs.

One way to mount a preference control

PreferencesSections draws every offered preference. First comes the core, the namespace theme: the theme picker, then density. After it comes each installed package's section. The panel with no children draws exactly that. namespace and only narrow it. For the core, "appearance" and "themeByAppearance" both name the picker, because the picker is one control for the two of them. There is no per-axis export and no standalone theme picker on the barrel. A host mounts <Preferences /> or <PreferencesSections />, and it cannot mount a control for a value the theme owns, because no such control exists.

What would reverse it: a host that needs one core preference on a surface, arranged in a way that only cannot express. The per-axis exports were that escape hatch, and their only consumer used them to mount the controls it should not have.

Held by packages/ui/src/index.test.ts, "drops components superseded by composition or a merge"; packages/ui/src/composites/Preferences.test.tsx, "draws the core as the namespace theme: the theme picker, then density".

A contributed choice can be drawn as density is: cards, each with a picture over its name. A manifest is data, declared in a package that may not depend on React, so it cannot carry a picture. The surface that draws the section passes them instead: specimens, keyed namespace.preference, a function from an option to what it looks like. Without one, the choice is a list.

Held by packages/ui/src/composites/Preferences.test.tsx, "draws a contributed choice's specimens from the surface that draws it, keyed namespace.preference".

A scope names a theme and a side, nothing else

KanzoTheme takes theme and appearance. A region that wants a different shape wears a different theme. Its density prop is gone because it never worked. Density sets a font-size, and a wrapper's font-size does not move a rem, which resolves against <html>. So a scoped density scaled the text that had no size of its own, and nothing else.

What would reverse it: sizes expressed in em relative to a scope, which the library does not use and has no reason to.

Held by packages/ui/src/theme/KanzoTheme.test.tsx, "writes the axes it is given and leaves the rest to the cascade".

Tenant policy: selection over preferences, authorship through themes

  • What the brand looks like is authored as a theme, including radius and typography. A tenant that wants square corners and its own face publishes a family from the generator.
  • What people may change is policy.theme, over the three preferences:
    • themes sets which themes are offered.
    • themeByAppearance: { hidden: true } means one brand and no choice.
    • appearance: { pinned: "dark" } makes a dark-only product.
    • density: { default: "compact" } starts the product dense. Density cannot be pinned.

References

  • GitHub's appearance settings offer theme mode (single, or sync with system), a day theme and a night theme. High-contrast variants ship as themes. There is no radius control and no interface-font control. themeByAppearance copies this model, and appearance now starts at the OS, as GitHub's does.
  • VS Code. workbench.colorTheme is a contributed theme. window.autoDetectColorScheme, with a preferred light theme and a preferred dark theme, is themeByAppearance. The counter-example is that editor.fontFamily is a setting: in a tool for reading code all day, the editor face passes the first test. A kanzo product that becomes an editor first is the case that would reopen the mono face.
  • daisyUI's theme carries the radii, the field and selector sizes, the border width, depth and noise beside the colours. Geometry is identity, and fonts are left to the host.
  • Radix Themes. <Theme radius scaling appearance>: radius and scaling are props the product sets, not controls for the end user.
  • shadcn/ui puts the radius in the theme's CSS beside the colours, and its theme gallery ships one per theme.
  • Material 3. Shape and type scales belong to the brand theme. What the person controls comes from the platform: dark theme, font scale and contrast level.

Not decided here

The graph's section is audited against the same rule in a separate change. Two of its preferences look like the product's rather than the person's: vignette, which describes itself as "mood rather than a reading aid", and the simulation coefficients, which are not appearance. Until that audit runs, they stay where they are.

On this page