Kanzo UI
Getting started

Contributing a section

How an optional package adds its own appearance tokens and its own user preference, without the core ever naming it.

Some packages carry appearance of their own. The graph has a marquee wash and a rubber-band edge; an editor would have its own type size. Neither belongs in the core's vocabulary — a role minted for one consumer is how a token list grows past anyone's ability to read it — and neither should cost a product that never installs the package.

A section is how such a package contributes both halves of what it owns:

halfwhat it iswho decides it
tokenscolours in the package's own namespace — --graph-marqueethe tenant, through their theme
prefschoices with names — how dense the marks are, whether links addthe user, in the Preferences panel

Both are optional. A package may contribute tokens without preferences, preferences without tokens, or both.

The manifest

One value, published by the package that owns the namespace, reached by subpath:

packages/graph/src/section.ts
import type { SectionManifest } from "@kanzo-tech/theme";

export const GRAPH_SECTION: SectionManifest = {
  namespace: "graph",
  version: 1,
  tokens: {
    marquee: {
      default: "color-mix(in oklab, var(--primary) 20%, transparent)",
      doc: "The wash over a rubber-band selection.",
    },
  },
  prefs: {
    marks: {
      kind: "choice",
      default: "dense",
      options: [
        { value: "dense", label: "Dense" },
        { value: "legible", label: "Legible" },
      ],
      doc: "How much ink a point spends.",
    },
    "additive-links": {
      kind: "toggle",
      default: "false",
      doc: "Links add where they overlap instead of compositing over one another.",
    },
    labels: {
      kind: "range",
      default: "26",
      min: 0,
      max: 60,
      step: 2,
      doc: "How many of the highest-degree nodes carry a standing label.",
    },
  },
};

Axes, not a list of names

This offered one choice of three — Nebula, Atlas, Ink — and the measurement that ended it is worth knowing before you declare a section of your own: six of the ten fields separating two of those names moved by 7–17%, under the package's own threshold for a difference meaning anything. Two pictures nobody can tell apart were about to become two names a person had to choose between.

Declare what a reader can name. The three names are still expressible — they are points in the space these axes span, and lookFrom builds one — but what the panel offers is the axes. See the measurement that decided it.

A token default is a binding, not a hex

color-mix(…, var(--primary) 20%, …) rather than #8839ef22. That is what makes a white-label theme reach a package the core has never heard of: a bank changes their theme and the marquee moves with it, because the default is a CSS value pointing at what their theme publishes. See Theming for the twenty-one it may point at.

The three kinds

A preference is one of exactly three, and the set is closed — there is no custom escape.

kindwhat it declaresdrawn as
choiceoptions, a closed list of { value, label }a radio list
togglenothing morea switch
rangemin, max, stepa slider
prefs: {
  marks: { kind: "choice", default: "dense", options: [...], doc: "…" },
  "additive-links": { kind: "toggle", default: "false", doc: "links add where they cross" },
  labels: { kind: "range", default: "26", min: 0, max: 60, step: 2, doc: "…" },
}

Every kind has a gate, and a value is always a string

A section may offer a value it has measured; it may not offer a text field. So each kind knows what an illegal value is — outside options, not "true"/"false", outside the declared bounds — and a stored value that fails declines to the default rather than being applied. That is the version-skew case: a slider that used to run to 5 and now stops at 2.5 must not paint 5 because storage remembers it.

Values are strings in all three. One storage shape is what lets an unrecognised namespace ride through a write without the core parsing it, and what lets a value reach a data-* attribute with no second spelling. Read them with prefBoolean and prefNumber rather than a local Number(), so a surface parses exactly what the resolver validated.

The host registers it

app/layout.tsx
import { KanzoThemeProvider } from "@kanzo-tech/ui";
import { GRAPH_SECTION } from "@kanzo-tech/graph/section";

<KanzoThemeProvider sections={[GRAPH_SECTION]}>{children}</KanzoThemeProvider>;

The arrow points host → core, and that is the whole design. Nothing in @kanzo-tech/theme or @kanzo-tech/ui names an optional package, so a product that never installed the graph cannot pass its manifest and pays nothing for it. Registration by import into the core would be the same mechanism with the dependency inverted — and it is checked rather than trusted: packages/theme/src/boundary.test.ts fails on a text match.

The panel then draws the section's preferences in the same list as radius and density. Nothing else is wired: that is the difference between one product and several sharing a window.

If you compose the panel yourself, place PreferencesSections

PreferencesPanel renders the canonical set when given no children. Passing children replaces it, so a host composing its own panel must place PreferencesSections itself, or every contributed choice silently disappears.

Where a value comes from

One chain, most specific first. It is the same shape the token half resolves by, deliberately: a preference resolved in some other order would be a second mechanism wearing the first one's vocabulary.

What the tenant pinned

A document may fix a choice. It outranks the user and no control is offered.

What the user stored

Unless the tenant pinned or withheld it.

The tenant's starting point

A different default, with the choice still offered.

useKanzoTheme().sectionPrefs carries the answer, keyed by namespace then by preference, with the declaration beside it so a control has the label and the options without a second lookup:

const { sectionPrefs, setSectionPref } = useKanzoTheme();

const marks = sectionPrefs.graph?.marks;
marks?.value; // "dense"
marks?.via; // "default" — which link of the chain answered
marks?.offered; // false when the tenant pinned or withheld it

setSectionPref("graph", "marks", "legible");

Tenant policy: selection, never authorship

A document may pin a choice, withhold it, or merely start it somewhere else. It may not invent a value for it — the options are the ones the owning package declared and measured.

<KanzoThemeProvider
  sections={[GRAPH_SECTION]}
  policy={{ graph: { marks: { pinned: "legible" } } }}
>

That is the white-label half, in one field: one client ships the graph fixed to legible marks and their users never see the control, another exposes it, and it is the same panel and the same code.

The core is a namespace like any other

theme — every axis the library itself declares, subject to the same three verbs:

<KanzoThemeProvider
  policy={{
    theme: { density: { default: "compact" }, radius: { pinned: "sm" } },
    graph: { marks: { pinned: "legible" } },
  }}
>

So a client ships our product is compact and square as the point their users start from, colour and geometry alike, and a user preference is an override on top of it. It used to be that a tenant could pin the graph's look and could not pin the radius: the newer mechanism had this chain and the older one read a stored blob.

Pass the same policy to `themeScript`

The pre-hydration script resolves the identical chain, and it can only resolve what it is given: themeScript({ policy }). A policy only React knows about paints the user's own value first and the client's a frame later — which is the flash the script exists to prevent, arriving through the feature meant to give a client control.

Pinned is not the same as withheld

pinned answers the question. hidden removes it and lets the default apply. Both stop a control being drawn, and both keep what the user had already chosen — so a tenant who stops pinning hands them their own choice back rather than a default.

What survives a package you no longer have

A user's contributed choices live under one key of the preferences blob, keyed by namespace, and the core never parses them. A namespace it does not recognise rides through every read and write untouched.

That is a product requirement rather than tidiness. A host that drops an optional peer for one release would otherwise lose the user's stored choice permanently, because the read-time whitelist drops every key it does not know — which is right for the retired colour axes it was built for and exactly wrong here. The same property holds one layer down for tokens: a client signs their document before they buy the graph, and the section a later package adds survives every load and save in between.

Validating

Both halves validate against the manifest that owns them, which is the part no surveyed system does — VS Code and Emacs let a package contribute without checking the namespace, and Vanilla Extract validates completely and therefore cannot have optional sections at all.

import { validatePrefs, validateSection } from "@kanzo-tech/theme";
import { GRAPH_SECTION } from "@kanzo-tech/graph/section";

validateSection reports a token the manifest never declared — which, unvalidated, would fall back to a parent name and paint something plausible. validatePrefs reports the same for a preference, and a stored value outside the declared options: a token's value is a colour and anything is a colour, but a preference has a closed set, so the stored-but-illegal case is expressible and worth saying out loud.

Validation is authoring-time. At paint time the cascade is the authority, so a token that validation would reject can still resolve if something upstream declared it.

On this page