Onboarding a tenant
Write a theme, ship the file, let an attribute choose — the work you do once per client.
There are two onboarding pipelines and they are easy to confuse, so this page is only the second one.
Installation is per product, once: the packages, the stylesheet, the provider, the pre-paint script. This is per client, once: you write their theme, it ships as a file, and every surface the client ever sees is a reading of it.
A theme is a block of CSS somebody writes; nothing derives it (colour has the measurement behind that). What you gain is that a theme is legible, pasteable and diffable. What you take on is contrast: nothing derives an ink for you. The last section says what still checks you.
Write the theme
A family is two files, one block and one mode each — or one family edited together in the
theme generator, which hands you both blocks. By hand: copy the nearest shipped
pair out of @kanzo-tech/theme/themes/ and change the colours.
/* @family acme
@label Acme */
[data-theme="acme"] {
color-scheme: light;
/* The twenty-one. Everything else in the vocabulary is a USE of one of these. */
--background: #fbfcfd; --foreground: #10151c;
--card: #ffffff; --muted: #eef1f5;
--muted-foreground: #4a5464;
--primary: #1f6feb; --primary-foreground: #ffffff;
--secondary: #e3e8ef; --secondary-foreground: #10151c;
--accent: #d6dde7; --accent-foreground: #10151c;
--destructive: #d61f27; --destructive-content: #fff8f7;
--info: #1155cc; --info-content: #f7fafe;
--success: #08794f; --success-content: #f4fbf8;
--warning: #b45c00; --warning-content: #fffaf5;
--border: #c2c9d4; --ring: #1f6feb;
/* Shape. These are what let a client look different without looking like a fork. */
--radius-box: 0.75rem; --radius-field: 0.5rem; --radius-selector: 0.25rem;
--size-field: 0.25rem; --size-selector: 0.25rem;
--stroke: 1px; --relief: 0;
/* Read with no fallback by the components, so every theme writes them. */
--input: #7a8496; --field: #ffffff; --faint: #586274;
/* Each status family read on the page — error text, an invalid ring. */
--destructive-foreground: #a3161c; --info-foreground: #0f4aad;
--success-foreground: #076b45; --warning-foreground: #8a4700;
/* The editor's inks — AA on --background and on --muted. */
--syntax-keyword: #7c3aed; --syntax-string: #0b7a4b; --syntax-number: #b54708;
--syntax-function: #1d4ed8; --syntax-variable: #10151c; --syntax-property: #0e7490;
--syntax-type: #9a5b00; --syntax-annotation: #c11574;
/* Optional: the categorical set. Omit it and the theme inherits the default eight; a theme
that DECLINES the channel says so with --chart-capacity: 0. */
--chart-1: #1f6feb; /* … through --chart-8 */
}Dark is a second theme, not a second block:
/* @family acme
@label Acme Dark */
[data-theme="acme-dark"] {
color-scheme: dark;
/* …the same set, chosen for a dark ground — plus --popover, if overlays should lift off the card. */
}Write the twenty-one and the extras — never the uses
Do not write the card ink, the sidebar brand or the other names bridged in tokens.css.
They are uses of the twenty-one and are declared once, centrally — writing them again in a theme
is the duplication the whole layer exists to prevent. Override one only when this theme genuinely
wants it different, which is a decision, not a default.
--secondary-foreground and --accent-foreground are in the vocabulary, not expected: a theme
that leaves them out wears --foreground on those fills, and only a theme whose secondary and
accent are strong fills needs an ink of its own. --popover is the same, and in practice only a
dark theme wants it.
Ship the file
Add it to the catalogue and regenerate:
pnpm --filter @kanzo-tech/theme genThat reads the themes/ directory, writes the @import list into themes.css and records each
theme's color-scheme, @family and @label in theme-data.json. Adding a theme is adding a
file — nothing lists them twice — and a family without exactly one light and one dark theme is
refused.
In a product's own repository there is no catalogue to regenerate: import the two files after
@kanzo-tech/ui/tailwind.css and list both sides in the provider's themes, as below.
Or ship it as configuration
An instance that is not this repository — one build, a brand per deployment — ships the same CSS as configuration instead. The generator's Instance config tab is that file:
branding:
theme_css: |
/* 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: falseThe host inlines theme_css after the library's stylesheet and maps the rest onto the provider:
families → themes, default → the defaultTheme pair, lock: true →
policy: { theme: { themeByAppearance: { hidden: true } } }. Where the file lives is the host's
call — an env var it names is the usual door; the mapping, a dozen lines, is in
Theming → Recipe: branding from an instance config.
Decide what this client's users may change
A tenant policy is keyed by namespace, and the core answers to theme like any other section. Pin
what the client insists on and withhold the control:
const ACME: ThemeOption[] = [
{ value: "acme", label: "Acme", dark: false, family: "acme" },
{ value: "acme-dark", label: "Acme Dark", dark: true, family: "acme" },
];
const policy = { theme: { themeByAppearance: { hidden: true }, radius: { pinned: "sm" } } };
<KanzoThemeProvider defaultTheme={{ light: "acme", dark: "acme-dark" }} policy={policy} themes={ACME}>
// …and the same `defaultTheme` and `policy` to `themeScript`, or the first paint disagrees.A withheld theme leaves the pair to the tenant, so the theme cards draw without swatches —
choosing a card still picks the side, and members still choose the side, inside the brand. That is the white-label case
working, not a control failing. Prefer hidden plus defaultTheme to pinning a theme name: a pinned
name is one theme on both sides.
Offer the choice, if there is one
<KanzoThemeProvider themes={[...ACME, ...themeIndex]} onThemeRetired={(name) => notify(name)}>
<PreferencesSections />
</KanzoThemeProvider>The client's family first makes it the default pair; the shipped families follow as alternatives.
PreferencesSections draws a light card and a dark card — because a theme is
a side, and choosing a card wears it — each a ThemePreview of its choice over a row of swatches, one per theme. The preview is the
theme painting a miniature screen, and each swatch is its real --background and --primary, so
the client's family reads as itself rather than as a name in a list.
When a client later withdraws a theme somebody had chosen, the provider moves that person to the
default and calls onThemeRetired once; the picker says so in an alert, and a host can toast
it from retiredTheme. See retired themes.
Verify
Contrast is checked against one list. CONTRAST_PAIRS in @kanzo-tech/theme names every pair a
theme owes and its WCAG floor — inks at 4.5:1, the syntax inks on the page and the active line, the
brand fill, ring, field border and chart slots at 3:1 — and auditContrast returns what a theme
fails. themes.test.ts holds every shipped theme to it in CI; the generator shows the same findings
live, on both sides, as you edit. A theme of your own runs through the same function (on both
blocks — a family passes when each side does):
import { auditContrast } from "@kanzo-tech/theme";
auditContrast((token) => acme[token]); // [] when every pair clears its floorauditContrast skips a pair whose token is missing rather than failing it, so check the theme is
whole too: the twenty-one and the authored extras,
including all eight --syntax-*.
Then look at it: the generator's Copy link reopens the pair with both sides drawn on real
components, and in an app, data-theme="acme" on <html> puts it on every screen you have.
Themes
Four families — kanzo, catppuccin, lofi and nord — each a day theme and a night theme. How to offer them, set the default, let people choose, theme a region, and write your own.
Contributing a section
How an optional package adds its own appearance tokens and its own user preference, without the core ever naming it.