Kanzo UI
Getting started

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:

FamilyDay (dark: false)Night (dark: true)
kanzo — the default, from the kanzo.tech brandkanzokanzo-dark
catppuccincatppuccin-lattecatppuccin-mocha
lofilofilofi-dark
nordnordnord-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:

PreferenceValuesWrites
themeByAppearance{ light?: string; dark?: string } — {} until they choosedata-theme, the theme for the side being worn
appearance"light" · "dark" — the OS's prefers-color-scheme until they chooseno 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" }
  • themeIndex is every shipped theme as a ThemeOption — { value, label, dark, family } — read off each file's color-scheme and its @family / @label header by the generator, so nothing lists the themes twice. The default family comes first, light before dark. It is the provider's default themes.
  • ThemeOption is the contract between a catalogue and a control. It carries no colours: a control depicts a theme by setting data-theme on an element and letting the cascade paint it. family pairs a theme with its other side; a tenant theme with no partner omits it.
  • themeFamilies(themes) groups any such list into ThemeFamily entries ({ 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 and themeScript's defaultTheme.

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>;
PropTypeDefault
themesThemeOption[]themeIndex
defaultTheme{ light?: string; dark?: string }defaultThemePair(themes)
policyRecord<string, SectionPolicy>{}
storageThemeStorage | nulllocalStorage
storageKeystring"kanzo_theme_prefs"
value / onChangePartial<ThemePrefs> / (next) => voiduncontrolled
defaultsPartial<ThemePrefs>—
sectionsSectionManifest[][]
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. Give themeScript the same value.
  • policy — what the tenant pins, withholds or starts elsewhere, keyed by namespace; the core is theme. See White-label. Give themeScript the same value.
  • storage — where an uncontrolled provider keeps what the user chose. cookieStorageAdapter(key?) makes it server-readable; null keeps nothing. storageKey is what the default storage and themeScript read.
  • 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 in themes; see Retired themes.
  • appearance — delegate .dark to 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 side

themeScript

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>
OptionDefaultMust match
storageKey"kanzo_theme_prefs"the provider's storageKey
defaultThemedefaultThemePair(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:

  1. clears that side's choice, so the default pair applies;
  2. calls onThemeRetired(name) — once per session, never during SSR, and never while themes is empty (a host still fetching its list must not wipe a valid choice);
  3. holds the name in retiredTheme for 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.themeEffect
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 hiddennothing 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.yml
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
KeyMeansMaps to
theme_cssthe theme blocksa <style> the host inlines in <head>, after the library's sheet
familiesthe families this instance offers, each with its light and dark ThemeOptionthemes — each side gets dark and family filled in. Empty: every shipped theme
defaultwhich family is worn until a member choosesdefaultTheme, that family's { light, dark }
lockwhether members may choosetrue → 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 --foreground when 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:

NameWhat it is
--inputa field's border — a boundary, so 3:1 against the page
--fielda field's fill
--faintthe quietest legible ink: placeholders, gutter numbers, code comments
the four status -foregroundsthe status family read on the page — error text, an invalid ring
the eight --syntax-*the editor's inks, below
--popoveran 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):

namewhat the shipped themes actually do
--sidebar --sidebar-foreground --accent-foregroundno 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-foregroundlofi and lofi-dark only, whose secondary is a dark (or light) fill; everywhere else it would be --foreground.
--popoverthe four dark themes and no light one.
--field --faint --inputevery theme. Genuine values, not repetitions.
the four status -foregroundsevery 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.

InkLezer tagsIn a fossil program
--syntax-keywordkeyword, controlKeyword, definitionKeyword, moduleKeyword, operatorKeyword, logicOperator, self; Markdown heading (bold)from, and / or / not; type and as via the semantic keyword
--syntax-stringstring, special(string), docString, character, regexp; Markdown monospace, liststring literals
--syntax-numbernumber, integer, float, unit, bool, null, atom, constant(name), standard(name), escapenumbers, true / false / null
--syntax-functionfunction(variableName), function(propertyName), macroName; url / link (underlined)functions (semantic function)
--syntax-variablevariableName; definition(variableName) at weight 500a binding — its declaration a step heavier than its uses (semantic variable, with the declaration modifier)
--syntax-propertypropertyName, definition(propertyName), attributeNamecolumns and fields (semantic property); parameters (semantic parameter → attributeName)
--syntax-typetypeName, definition(typeName), className, tagName, labelNametypes (semantic type)
--syntax-annotationspecial(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

TokenDefaultSet by
--font-sans, --font-headingGeist (--font-geist-sans), falling back to the system facethe theme
--font-monoGeist Mono (--font-geist-mono), falling back to the system monothe 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.

knobgoverns
--radius-box / --radius-field / --radius-selectorcard and dialog / button, input and tab / checkbox and badge
--size-field / --size-selectorthe unit a control's height is a multiple of
--strokeline weight
--reliefrelief, 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
--noisegrain, 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.

GroundRead on itFloor
--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--foreground4.5:1
--accent--accent-foreground, --muted-foreground4.5:1
--primary, --secondaryeach its own -foreground4.5:1
--destructive, --info, --success, --warningeach its own -content4.5:1
--sidebar--sidebar-foreground4.5:1
--background--primary, --ring, --input, --chart-1 … --chart-83: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 floor

The user's preferences

PreferenceWritesSets
appearance.darkwhich side is worn; the OS's prefers-color-scheme until the person picks
themeByAppearancedata-themethe theme worn on that side
densitydata-font-sizethe 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's

A 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 larger

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

On this page