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, andthemes.cssemitted the radius blocks after the theme imports. So the person's step won over the brand's radius.- The slider's
mdstep wrote no attribute, so atmdthe 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:
- 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.
- 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
| axis | verdict | reason |
|---|---|---|
appearance | preference | Ambient light and eyes. Its starting point is the OS's, see below. |
themeByAppearance | preference, among the tenant's themes | The 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. |
density | preference, and never the tenant's | Screen size and eyesight. It is the one axis with a measured bar, WCAG 2.5.8 in OBLIGATIONS. |
| radius | theme | It fails the first test. Every theme authors all three radii, and the generator edits them. |
| sans, heading and mono faces | theme | They fail the first test. A face is the most recognisable part of a brand after its colour. |
shape knobs (--size-field, --stroke, --relief, --noise) | theme | They never were preferences. |
| chart categorical set | theme | It is part of colour, see colour. |
| motion | platform | prefers-reduced-motion already asks. The animated recipes honour it with motion-reduce:. |
| contrast | platform, plus the theme's floor | Every theme clears AA (auditContrast). Wanting more is the platform's question, see below. |
| contributed sections | the same rule, applied later | The 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:
| consumer | derives |
|---|---|
ThemePrefs / DEFAULT_PREFS | the keys |
AXES | the rows that write an attribute: data-theme and data-font-size |
themes.css | the catalogue's imports and the density steps, and nothing else |
KanzoThemeProvider storage | PREF_KEYS, the read-time whitelist, which drops a stored radius, font or monoFont with no migration |
policy.theme | the declared keys, with personal honoured |
themeScript | the same rows |
PreferencesSections | every 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:themessets 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.
themeByAppearancecopies this model, and appearance now starts at the OS, as GitHub's does. - VS Code.
workbench.colorThemeis a contributed theme.window.autoDetectColorScheme, with a preferred light theme and a preferred dark theme, isthemeByAppearance. The counter-example is thateditor.fontFamilyis 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.
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.
The graph view
How @kanzo-tech/graph is built — one Mosaic client on the page's coordinator over a corpus fossil attached, uploaded to cosmos.gl once and greyed out by the crossfilter, an Ark-shaped root over flat parts — and what would reverse each rule.