Theming
The model — families, a light theme and a dark theme — the provider and the pre-paint script, white-label policy, every token a theme authors, and the contrast floors it owes.
Two different things wear the word "theme", and this page is mostly about keeping them apart.
What a client is is a theme: one hand-written block of CSS under [data-theme="<name>"]
carrying its colours, the shape knobs and, if it wants them, its font stacks — and roughly thirty
decisions, because the theme generator fills in the ink that follows from a
fill. Adding a client is adding a file.
What a user chooses is which theme they wear on each side, which side is worn, and how dense
everything is. How round the corners are and which typefaces are used are the theme's, never the
user's. All of it reaches <html> as an attribute or a class whose values live in static CSS.
The rule about colour is not "the user touches no colour" — appearance is colour, and always was.
It is: the user never authors a colour value; they choose among themes someone already wrote.
Looking for the list of themes?
Themes is the catalogue — four families, each drawn live — with the short version of everything below: how to offer them, set the default, let people choose and write your own.
The model
A theme is one mode. Light and dark are two themes, not two blocks of one document — daisyUI's arrangement, adopted after measurement — so the library ships families: one light theme and one dark theme that belong together. Four ship, eight themes in all:
| Family | Day (dark: false) | Night (dark: true) |
|---|---|---|
kanzo — the default, from the kanzo.tech brand | kanzo | kanzo-dark |
catppuccin | catppuccin-latte | catppuccin-mocha |
lofi | lofi | lofi-dark |
nord | nord | nord-dark |
A person therefore chooses the way GitHub's Settings → Appearance lets them: a light theme, a dark theme, and which of the two is worn. Two preferences hold it:
| Preference | Values | Writes |
|---|---|---|
themeByAppearance | { light?: string; dark?: string } — {} until they choose | data-theme, the theme for the side being worn |
appearance | "light" · "dark" — the OS's prefers-color-scheme until they choose | no attribute: it writes .dark |
The two compose rather than compete: appearance picks the side, and the theme axis is keyed by that
side. While nothing is stored the side is the OS's; once the person picks, the pick is stored and
wins. A tenant moves the starting side with policy={{ theme: { appearance: { default: "dark" } } }},
which outranks the OS.
Radius and typography are not preferences: each theme authors its own three radii and may name its faces. See Preference or theme.
Why one mode per theme
It is what lets a tint be written as a percentage on the role (bg-destructive/7) with one meaning
per theme, what makes a scoped preview a plain div, and why "which theme" is stored per side: a
theme is a side, so the question without the side does not name a preference. The argument and
the numbers are in the colour document.
The catalogue as data
import { defaultThemePair, themeFamilies, themeIndex } from "@kanzo-tech/theme";
themeIndex[0]; // { value: "kanzo", label: "Kanzo", dark: false, family: "kanzo" }
themeFamilies(themeIndex); // [{ family: "kanzo", light: {…}, dark: {…} }, { family: "catppuccin", … }, …]
defaultThemePair(themeIndex); // { light: "kanzo", dark: "kanzo-dark" }themeIndexis every shipped theme as aThemeOption—{ value, label, dark, family }— read off each file'scolor-schemeand its@family/@labelheader by the generator, so nothing lists the themes twice. The default family comes first, light before dark. It is the provider's defaultthemes.ThemeOptionis the contract between a catalogue and a control. It carries no colours: a control depicts a theme by settingdata-themeon an element and letting the cascade paint it.familypairs a theme with its other side; a tenant theme with no partner omits it.themeFamilies(themes)groups any such list intoThemeFamilyentries ({ family, light?, dark? }), in the order the families first appear — what the catalogue draws.defaultThemePair(themes)is the pair a document wears while nobody has chosen: the first light theme and its family's dark one (or the first dark theme, if that family has none). It is the default of both the provider's andthemeScript'sdefaultTheme.
Wiring it up
Installation has the three lines every product needs. This is what each piece takes.
KanzoThemeProvider
Owns the preferences, resolves them through the tenant's policy and writes the result to <html>.
There is exactly one, at the root.
import { KanzoThemeProvider, cookieStorageAdapter } from "@kanzo-tech/ui";
<KanzoThemeProvider storage={cookieStorageAdapter()}>{children}</KanzoThemeProvider>;| Prop | Type | Default |
|---|---|---|
themes | ThemeOption[] | themeIndex |
defaultTheme | { light?: string; dark?: string } | defaultThemePair(themes) |
policy | Record<string, SectionPolicy> | {} |
storage | ThemeStorage | null | localStorage |
storageKey | string | "kanzo_theme_prefs" |
value / onChange | Partial<ThemePrefs> / (next) => void | uncontrolled |
defaults | Partial<ThemePrefs> | — |
sections | SectionManifest[] | [] |
onThemeRetired | (theme: string) => void | — |
themes— the themes on offer. The picker draws these, the retirement check reads them, and the default pair is taken from them. A host still has to load the CSS of any theme it adds.defaultTheme— the pair worn while the user has chosen nothing. A pair, never one name. GivethemeScriptthe same value.policy— what the tenant pins, withholds or starts elsewhere, keyed by namespace; the core istheme. See White-label. GivethemeScriptthe same value.storage— where an uncontrolled provider keeps what the user chose.cookieStorageAdapter(key?)makes it server-readable;nullkeeps nothing.storageKeyis what the default storage andthemeScriptread.value/onChange— controlled mode: the host owns persistence (a user profile on the server, say).defaults— the app's starting point for a preference, one rank below the tenant's policy.sections— the preference sections of the packages this host installed; see Contributing a section.onThemeRetired— called at most once when the stored theme is no longer inthemes; see Retired themes.appearance— delegate.darkto a host theme manager such as next-themes; see Dark mode.
The provider always writes the resolved theme to data-theme — the user's choice for the side
being worn, or the default pair's — never leaving the attribute off for "the default". The CSS
default binding (:root:not([data-theme])) can name only one family, and a tenant whose default is
its own family must not depend on it.
Read or change the state with useKanzoTheme():
const { resolvedTheme, appearance, setTheme, setAppearance } = useKanzoTheme();
setTheme("nord-dark", { appearance: "dark" }); // file a dark theme without repainting the light
setAppearance("dark"); // wear the dark sidethemeScript
The provider runs after hydration; the first paint happens before it. themeScript() returns a tiny
inline script that resolves the same chain — cookie, then localStorage, then policy, then the
default pair — and writes the same data-* attributes and .dark before anything is drawn.
import { themeScript } from "@kanzo-tech/ui";
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript({ defaultTheme, policy }) }} />
</head>| Option | Default | Must match |
|---|---|---|
storageKey | "kanzo_theme_prefs" | the provider's storageKey |
defaultTheme | defaultThemePair(themeIndex) | the provider's defaultTheme |
policy | {} | the provider's policy (only the theme namespace is read) |
Why the same values, and why it is mandatory under SSR. If the script falls back to kanzo
while the provider falls back to the tenant's family, the page flashes one family and then repaints
into the other; a pinned axis the script does not know about flashes the user's own value first.
theme-script.test.ts runs the script and the provider under one set of stubs and diffs <html>,
so the two cannot drift.
The script owns <html> and nothing else. What the provider renders — the Preferences sections —
starts from the defaults on the server and on the hydrating client alike, and
the stored choice arrives in a layout effect before the first paint, so hydration never meets a
stored dark side over markup the server drew light.
Pair it with cookieStorageAdapter() on the provider so the user's writes land where the script
(and a server rendering the request) can read them.
The theme cards and ThemePreview
PreferencesSections draws the one control for both
preferences: a light card and a dark card, which are the appearance choice themselves, each a large
preview over a row of theme swatches. ThemePreview is the miniature screen on each card, a region wearing the
theme and painted by its real tokens. The cards offer what themes lists and obey policy; the
preferences page has the full behaviour table.
Retired themes
A theme the user chose can disappear: the host narrows themes, a tenant drops a family, or the
library retires one. The cascade already copes — an attribute with no
matching rule is inert — but a person who chose gold and is looking at blue reads silence as a bug.
So the provider, on finding that the theme worn on the current side is not in themes:
- clears that side's choice, so the default pair applies;
- calls
onThemeRetired(name)— once per session, never during SSR, and never whilethemesis empty (a host still fetching its list must not wipe a valid choice); - holds the name in
retiredThemefor the rest of the session, because the place that says so may mount later.
The theme cards then show an info alert, translated through the cards' translations
(retiredTitle, and retired(theme) for the body). A host that wants it said as a toast too —
where a tenant withholds the theme, there are no cards to say it — reads the same retiredTheme and
toasts it once; the composition is an example to copy.
The library ships no notice of its own: it would be the effect in that example under a name.
One themer, and it owns the root
KanzoThemeProvider writes the axes to document.documentElement and is the themer. There is
no scoped one on the barrel: a region that wears another theme — a preview, a branded panel — is the
attribute and the side's class on an element, as the themes page shows,
and ThemePreview is the drawn preview the theme cards use.
A region is never an app's chrome. Ark's overlays — Dialog, Popover, Menu, Select, Tooltip,
Toast, HoverCard, Command — portal into document.body, outside any wrapper, so a wrapper cannot
theme them; and density sets the root font-size that the whole rem scale resolves against, so a
region does not take it at all.
The scoped themer, KanzoTheme, was on the barrel for previews and no product called it, so it
left: a name is admitted by a product's call site (Admission), and its one
caller is ThemePreview, inside the package.
What would reverse it: a product that themes regions of its own pages — two of them, each wanting the context re-pointed as well as the attribute — which is the call site that would admit a scoped themer again. A region that could theme Ark's portalled overlays would not: it cannot, and that is a fact about the portal target rather than a preference.
Held by packages/ui/src/index.test.ts, "exposes one themer, the root's, and no scoped one", and
!KanzoTheme.
White-label
A white-labelled instance is one product wearing a client's brand. Three levers, and all of them are
props you already met: themes (what is offered), defaultTheme (what is worn by default) and
policy (what the user may change).
Locking the theme
Withhold the theme axis and pass the brand as the default pair:
const pair = { light: "acme", dark: "acme-dark" };
const policy = { theme: { themeByAppearance: { hidden: true } } };
<KanzoThemeProvider defaultTheme={pair} policy={policy}>
// …and themeScript({ defaultTheme: pair, policy }) in <head>hidden means do not offer this, and ignore what was stored: the provider applies the pair, and
the theme cards draw without swatches, so a member still chooses the side — inside
the brand. Withhold appearance too and the picker draws nothing.
policy.theme | Effect |
|---|---|
themeByAppearance: { hidden: true } | the brand is locked; the mode is still a choice |
appearance: { pinned: "dark" } | always the night side; the picker offers that side's themes only |
appearance: { hidden: true } | the tenant's default side; the control goes, the light and dark cards remain |
both hidden | nothing to choose; the theme section renders nothing |
density: { default: "compact" } | the product starts dense; density cannot be pinned or withheld, it is always the person's |
Prefer hidden plus defaultTheme over pinned for the theme: a pinned name is one theme for
both sides, which paints a light theme at night.
Offering a subset
<KanzoThemeProvider themes={themeIndex.filter((t) => t.family !== "lofi")}>The picker, the default pair and the retirement check all follow the list. The CSS of the themes left out stays in the page, inert.
A tenant's own family
Write the pair (the generator hands you both blocks), load the CSS after the library's stylesheet, and list both sides — first, if it is the default:
const ACME: ThemeOption[] = [
{ value: "acme", label: "Acme", dark: false, family: "acme" },
{ value: "acme-dark", label: "Acme Dark", dark: true, family: "acme" },
];
<KanzoThemeProvider themes={[...ACME, ...themeIndex]}>Recipe: branding from an instance config
When the brand is configuration rather than code — one build, many instances — let the generator's
Instance config tab be the file format. It is a branding: block the host reads as-is:
branding:
theme_css: |
/* @family acme
@label Acme */
[data-theme="acme"] {
color-scheme: light;
--background: #fbfcfd;
/* …both blocks, exactly as the Theme CSS tab shows them */
}
families:
- family: acme
light: { value: acme, label: Acme }
dark: { value: acme-dark, label: Acme Dark }
default: acme
lock: false| Key | Means | Maps to |
|---|---|---|
theme_css | the theme blocks | a <style> the host inlines in <head>, after the library's sheet |
families | the families this instance offers, each with its light and dark ThemeOption | themes — each side gets dark and family filled in. Empty: every shipped theme |
default | which family is worn until a member chooses | defaultTheme, that family's { light, dark } |
lock | whether members may choose | true → policy: { theme: { themeByAppearance: { hidden: true } } } |
families replaces the catalogue rather than adding to it. To offer a shipped family beside the
brand, list it too — its CSS is already in the page, so it needs no theme_css:
families:
- family: acme
light: { value: acme, label: Acme }
dark: { value: acme-dark, label: Acme Dark }
- family: nord
light: { value: nord, label: Nord }
dark: { value: nord-dark, label: Nord Dark }The mapping is a dozen lines on the host. This one reads the file named by an env var of the
host's choosing, validates it at boot (every family names a light and a dark theme, default
is one of them, unknown keys are refused) and serves it to the web app:
function themeLook(branding: Branding) {
const themes: ThemeOption[] =
branding.families.length === 0
? themeIndex
: branding.families.flatMap(({ family, light, dark }) => [
{ ...light, dark: false, family },
{ ...dark, dark: true, family },
]);
const chosen = branding.families.find((f) => f.family === branding.default);
const defaultTheme = chosen
? { light: chosen.light.value, dark: chosen.dark.value }
: defaultThemePair(themes);
const policy = branding.lock ? { theme: { themeByAppearance: { hidden: true } } } : undefined;
return { themes, defaultTheme, policy };
}
// in the root layout's <head>
<style dangerouslySetInnerHTML={{ __html: branding.theme_css }} />
<script dangerouslySetInnerHTML={{ __html: themeScript({ defaultTheme, policy }) }} />
// around the app
<KanzoThemeProvider defaultTheme={defaultTheme} policy={policy} themes={themes}>The same defaultTheme and policy reach both the script and the provider, which is the one rule
the recipe cannot get wrong. The per-client checklist — writing the pair, checking contrast, deciding
what users may change — is Onboarding a tenant.
The twenty-one, and the uses
The vocabulary has twenty-one colours a theme authors. Every other name is a use of one of them,
declared once in tokens.css through @theme inline and never re-declared.
- Surfaces and ink —
--background,--foreground,--card,--muted,--muted-foreground - Brand —
--primary,--secondary,--accent, each with its-foreground(the last two fall back to--foregroundwhen a theme leaves them out) - Status —
--destructive,--info,--success,--warning, each with its-content - Lines —
--border,--ring
Beside them, every shipped theme authors a few names the components read with no fallback, so a theme of your own must write them too:
| Name | What it is |
|---|---|
--input | a field's border — a boundary, so 3:1 against the page |
--field | a field's fill |
--faint | the quietest legible ink: placeholders, gutter numbers, code comments |
the four status -foregrounds | the status family read on the page — error text, an invalid ring |
the eight --syntax-* | the editor's inks, below |
--popover | an overlay's surface — authored by the dark themes, where it lifts off the card; the light ones let it fall back to --card |
--popover, --field, --faint, --input, the eight sidebar names and the status
-foregrounds are uses in the vocabulary. A theme overrides one only when it genuinely wants it
different — and it is worth knowing which actually do, because "is a use" and "is left alone" are
different claims. Measured over the eight shipped themes (2026-10-01):
| name | what the shipped themes actually do |
|---|---|
--sidebar --sidebar-foreground --accent-foreground | no theme authors one. Each would repeat another token in every file, so the bridge names the token and no theme restates it. --accent is a surface, not a fill, so its ink is the page's. |
--secondary-foreground | lofi and lofi-dark only, whose secondary is a dark (or light) fill; everywhere else it would be --foreground. |
--popover | the four dark themes and no light one. |
--field --faint --input | every theme. Genuine values, not repetitions. |
the four status -foregrounds | every theme, each pageInk(fill, --foreground, --background); left to the bridge they would resolve to the fill at full strength. |
the eight --syntax-* | every theme, each at AA on --background and on --muted. |
The second row is the shape of the rule working: the same name is a repetition in one theme and a real value in another, so what earns a declaration is measured per file rather than decided once for the vocabulary.
Why a use is bridged and never declared in :root
:root { --sidebar-primary: var(--primary) } looks equivalent to bridging it and is not. A custom
property inherits its computed value, so it resolves once on <html> — and a scoped
<div data-theme="catppuccin-mocha"> then inherits the already-substituted answer, painting Catppuccin's
brand with the page's sidebar. Silently: nothing about it is a compile error. @theme inline puts
the value in the utility, so the var() resolves on the element wearing the class.
Syntax inks and the editor
Eight inks colour code, and every theme authors all eight at AA on the editor's paper
(--background) and on its active line (--muted). CodeEditor maps
Lezer highlight tags onto them, so any
CodeMirror language is coloured by whichever theme is on the page; kanzoHighlighting from
@kanzo-tech/ui/editor is the same mapping as an extension for an editor you assemble yourself.
| Ink | Lezer tags | In a fossil program |
|---|---|---|
--syntax-keyword | keyword, controlKeyword, definitionKeyword, moduleKeyword, operatorKeyword, logicOperator, self; Markdown heading (bold) | from, and / or / not; type and as via the semantic keyword |
--syntax-string | string, special(string), docString, character, regexp; Markdown monospace, list | string literals |
--syntax-number | number, integer, float, unit, bool, null, atom, constant(name), standard(name), escape | numbers, true / false / null |
--syntax-function | function(variableName), function(propertyName), macroName; url / link (underlined) | functions (semantic function) |
--syntax-variable | variableName; definition(variableName) at weight 500 | a binding — its declaration a step heavier than its uses (semantic variable, with the declaration modifier) |
--syntax-property | propertyName, definition(propertyName), attributeName | columns and fields (semantic property); parameters (semantic parameter → attributeName) |
--syntax-type | typeName, definition(typeName), className, tagName, labelName | types (semantic type) |
--syntax-annotation | special(variableName), annotation, namespace | @subject, @rename (special(variableName)); an @connection reference (semantic namespace) |
The rest reuse tokens that already carried the right decision: comments and meta are --faint
(italic for comments), punctuation is --muted-foreground, operators are --foreground, a diff's
inserted / deleted / changed lines are --success / --destructive / --warning, and invalid is
--destructive-foreground. --syntax-annotation is deliberately not --faint: an annotation
in a mapping is a load-bearing word, not a comment.
The fossil column comes from @fossil-lang/codemirror-fossil: its lexer tags literals, operators and
@-markers, and the semantic tokens of fossil's compiler paint identifiers over them — typeName,
definition(…) for a declaration, namespace, attributeName — which is why a theme needs no
fossil-specific token.
Fonts
| Token | Default | Set by |
|---|---|---|
--font-sans, --font-heading | Geist (--font-geist-sans), falling back to the system face | the theme |
--font-mono | Geist Mono (--font-geist-mono), falling back to the system mono | the theme |
The stacks are declared as plain values in a static @theme, so they are emitted even where no
utility asks for them — CodeEditor reads --font-mono from a style object. They used to be
spelled as var()s of their own names inside the bridge, a cycle that made --font-mono compute to
nothing for a host that set it without a fallback; that is fixed. Load Geist — the geist package's next/font exports define --font-geist-sans and
--font-geist-mono — or the system faces apply.
The shape knobs
They are what let a client look different without looking like a fork.
| knob | governs |
|---|---|
--radius-box / --radius-field / --radius-selector | card and dialog / button, input and tab / checkbox and badge |
--size-field / --size-selector | the unit a control's height is a multiple of |
--stroke | line weight |
--relief | relief, 0–1, multiplied into the shadow and the fill's edge. Not --depth: that name is Zag's, set per node on a tree item |
--noise | grain, 0–1; undeclared means off |
--relief is a number, not a branch: the recipes already contain
calc(var(--relief) * 30%), so 0 collapses the shadow to nothing and 1 gives it relief, from one
stylesheet with no conditional in it. --noise shows the same pattern: it multiplies the size of a
grain layer the recipes already paint —
background-image: var(--fx-noise);
background-size: calc(var(--noise, 0) * 100%);— so at 0 the layer is sized to nothing and never drawn. No shipped theme declares it, so
var(--noise, 0) is where "off" lives.
A knob nothing reads is worse than a missing one
A tenant sets it, nothing moves, and the theme looks broken rather than ignored.
knobs-are-read.test.ts fails for any knob that reaches neither a var() in the sheet nor a
utility the library uses.
What the generator edits
/theme-generator edits a family — the light theme and the dark one together —
with an OKLCH picker per authored colour: the twenty-one, the four status -foregrounds, --input
and the eight --syntax-*. Shape is shared by both sides. --popover, --field, --faint and the
chart set carry no picker and are emitted anyway, read off the family it started from, so what it
hands you is a whole theme. --sidebar has neither a picker nor a value: no theme authors it, its
chain is --sidebar → --popover → --card, and a picker would let a generated theme author a name
every shipped theme declined to.
Contrast
CONTRAST_PAIRS in @kanzo-tech/theme is every pair a theme owes, as ContrastPairs
({ ground, ink, min }), and auditContrast(resolve) returns the ContrastFindings — the same pairs
with their ratio — that a theme fails. One list serves the CI guard and the generator, so the page
that authors a theme cannot pass what the build rejects.
| Ground | Read on it | Floor |
|---|---|---|
--background | --foreground, --muted-foreground, the four status -foregrounds, the eight --syntax-* | 4.5:1 |
--muted — also the editor's active line | --muted-foreground, the eight --syntax-* | 4.5:1 |
--card, --popover | --foreground | 4.5:1 |
--accent | --accent-foreground, --muted-foreground | 4.5:1 |
--primary, --secondary | each its own -foreground | 4.5:1 |
--destructive, --info, --success, --warning | each its own -content | 4.5:1 |
--sidebar | --sidebar-foreground | 4.5:1 |
--background | --primary, --ring, --input, --chart-1 … --chart-8 | 3:1 |
4.5:1 is WCAG 1.4.3 for body text. 3:1 is WCAG 1.4.11 for a component's boundary or a graphical object — a focus ring, a field's border, a chart mark, and a brand fill that has to stand out from the page.
--accent is measured against both inks because here it is a surface — the ground a hovered or
selected row wears — not a third brand fill as in daisyUI.
How CI enforces it. themes.test.ts, "keeps every pair at its WCAG floor once resolved", resolves
every shipped theme through the bridge and runs auditContrast over it; pnpm verify — the one CI
gate — fails on any finding. A pair whose side is not an opaque hex is skipped rather than failed:
a missing token is a different defect, reported by the guards that resolve the bridge.
How the generator warns. It runs the same auditContrast on both sides on every edit: the
contrast panel lists each failing pair by token with its ratio and floor, and a warning mark sits on
the side switch. It never refuses — a warning is a fact about the draft.
Checking a theme of your own is the same call:
import { auditContrast } from "@kanzo-tech/theme";
const acme = { "--background": "#fbfcfd", "--foreground": "#10151c" /* … */ };
auditContrast((token) => acme[token]); // [] when every pair clears its floorThe user's preferences
| Preference | Writes | Sets |
|---|---|---|
appearance | .dark | which side is worn; the OS's prefers-color-scheme until the person picks |
themeByAppearance | data-theme | the theme worn on that side |
density | data-font-size | the root font-size as a percentage of the browser's (87.5% · 100% · 112.5%), which every rem scales from |
That is the whole list. Every one is declared, not hand-wired: CORE_PREFS is generated beside
the CSS by packages/theme/scripts/gen-theme.mjs, so the options a control offers are the table the
selectors were emitted from. AXES is a projection of it.
import { CORE_PREFS, prefOptions } from "@kanzo-tech/theme";
CORE_PREFS.density.attr; // "data-font-size"
prefOptions(CORE_PREFS.density); // [{ value: "default", label: "Default" }, …]
CORE_PREFS.themeByAppearance.options; // { from: "themes" } — the list is the TENANT'sA tenant may pin, withhold or restart them through one policy prop, under the namespace theme;
see contributing a section, which is the
same mechanism. Density is declared personal: a tenant may start it elsewhere and may not pin or
withhold it.
They must be on <html>, not a wrapper
Ark's overlays — Dialog, Popover, Menu, Select, Tooltip, Toast, HoverCard, Command — portal
into document.body, outside any element you render. Tokens set on a
wrapper never reach them. Density is stricter still: it sets the root font-size, and every
size in the system is rem.
This is why there is exactly one provider, and why it writes to the document rather than rendering an element.
Setting them
<KanzoThemeProvider defaults={{ density: "compact" }}>{children}</KanzoThemeProvider>const { density, set } = useKanzoTheme();
set({ density: "comfortable" }); // everything a step largerOr compose the ready-made panel, which draws the theme cards first and density after them:
import { PreferencesPanel, PreferencesRoot, PreferencesTrigger } from "@kanzo-tech/ui";
<PreferencesRoot>
<PreferencesTrigger />
<PreferencesPanel />
</PreferencesRoot>PreferencesRoot takes an optional hotkey. It has no default on purpose — a design system
should not claim an unmodified key in its host's global keymap without being asked.
Dark mode
.dark on <html> is the only mode selector, and KanzoThemeProvider writes it from the side
the resolved side — "light" | "dark", nothing else. While nothing is stored, both the provider and
themeScript start from prefers-color-scheme; a stored pick wins, and the OS is never stored. A host already running a theme manager
that writes the class (next-themes with attribute: "class") turns that writer off.
Why a class and not light-dark()
Two reasons. The recipes' dark: variants need a class to key off; and
light-dark() makes per-mode values unreadable to getComputedStyle, which is exactly how
the charts layer resolves var(--chart-N) for Observable Plot.
Each theme file carries its own color-scheme. Never write
documentElement.style.colorScheme — an inline declaration outranks every rule, permanently.
Fills and their inks
Two families, two different rules — this trips people up, so it is worth stating plainly.
Neutral and brand: the base name is the FILL, and -foreground is the text that sits ON
that fill. bg-primary text-primary-foreground is a readable pair.
Status (destructive / info / success / warning): three tokens, not two, and the extra
one is the point. -foreground is a readable-on-background variant of the same hue — for
error text, invalid rings and destructive menu items. That it is not readable on the fill
is not a contrast bug; it is Shark's convention, adopted verbatim.
So on-fill text gets its own token, -content — daisyUI's word for exactly this role. It is
authored: nothing derives it at runtime. What proposes one is the generator, at authoring time —
pick a fill and both of its inks are filled in, -content by daisyUI's own rule and -foreground
by pageInk. Both are proposals you overwrite, and what ships is the literal in the file.
bg-destructive text-destructive-content is the readable pair. A literal text-white is not:
measured, that white sits at 2.13:1 on the warning fill and 3.81 on destructive.
What @kanzo-tech/theme exports besides CSS
The stylesheet is the product; these are the shapes a host needs when it builds its own control, its own storage, or its own section. None of them is required to use a theme.
The catalogue. themeIndex, ThemeOption, themeFamilies, ThemeFamily and
defaultThemePair — see the catalogue as data. themeData is the generated
tables as a JS module — read it rather than importing theme-data.json, which Node rejects as a bare
JSON import — and ThemeData is its type. The preference types are Appearance
and KanzoDensity; a theme name is a string, host-extensible, and "" means defer to the tenant's default
rather than no theme.
Storage. STORAGE_KEY is where a preference document is kept, and CORE_NAMESPACE is the
namespace the core's own preferences answer to inside a tenant's policy.
Preferences. CorePrefDecl and CorePrefKey are the core's axes declared in the shape any
package contributes. A choice offers PrefOptions — value is stored, label is read — as a
PrefOptions list or, when an author cannot list them, a PrefSource naming who owns the list.
resolvePref walks one chain, most specific first, and the PrefOrigin it returns is what lets a
panel say where a value came from and a test assert on it.
Sections. A stored appearance's sections are opaque: the core never parses a section's
payload. A section declares SectionPrefDecls for the person on the screen and SectionTokenDecls
for the document; a tenant answers with a SectionPolicy of SectionPrefPolicy, which is
selection, never authorship. validateSection checks a payload against its manifest, and a
Problem is a token the manifest does not declare.
Authoring colour. inkFor returns the ink that belongs on a fill and pageInk the same family
read on the page, each as a hex ready to be written into a theme. contrast is the WCAG ratio, AA
its 4.5 floor, and oklch/hex convert between a hex and an Oklch (l from 0 to 1, h in
degrees). CONTRAST_PAIRS and auditContrast are the floors. These run in a form while
somebody picks a colour, never in a page painting one.
Generated output
themes.css and theme-data.json are generated by scripts/gen-theme.mjs — the catalogue of
@imports, each theme's color-scheme / @family / @label, the user's non-colour preferences,
and the table of what each name defers to, read out of tokens.css so nothing restates it. Edit the
generator, not those two; CI regenerates and fails on any diff. The generator refuses a family
without exactly one light and one dark theme.
tokens.css and themes/*.css are NOT generated. They are hand-written source, and a guard
that regenerated them would have nothing to regenerate them from.