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 dot grid and a hover ring; 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-gridthe 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:

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

export const GRAPH_SECTION = {
  namespace: "graph",
  tokens: {
    grid: { default: "var(--input)", doc: "The dot grid behind the canvas." },
    "point-ring-hover": { default: "var(--primary)", doc: "The ring around a hovered point." },
  },
  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.",
      when: { pref: "edges", neq: "hidden" },
    },
    "x-by": {
      kind: "choice",
      default: "",
      options: { from: "numeric-columns" },
      when: { pref: "placement", eq: "map" },
      doc: "The numeric column the points are placed across by.",
    },
    gravity: {
      kind: "range",
      default: "0.14",
      min: 0,
      max: 0.5,
      step: 0.01,
      doc: "Pull toward the centre.",
    },
  },
} as const satisfies SectionManifest;

as const satisfies keeps the names and the options' values literal, which is what lets an app's Register check <Pref name> against them.

Axes, not a list of names

Declare what a reader can name. A named look is a point in the space the axes span — lookFrom builds one from them — so the panel offers the axes, not a list of looks two people could not tell apart. See the measurement that decided it.

A token default is a binding, not a hex

var(--border) rather than #c9c9c9. That is what makes a white-label theme reach a package the core has never heard of: a bank changes their theme and the grid 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 row of radio cards
choiceoptions: { from }, a list its owner answersa Select
togglenothing morea switch
rangemin, max, stepa slider

Where a list's options come from decides its control, never how many there are: a list the author wrote is a few measured values, drawn as cards; a list the data answers is a pop-up, Apple's HIG's rule. Clearing a Select stores "", which a sourced choice takes as its default.

prefs: {
  marks: { kind: "choice", default: "dense", options: [...], doc: "…" },
  "additive-links": { kind: "toggle", default: "false", doc: "links add where they cross" },
  gravity: { kind: "range", default: "0.14", min: 0, max: 0.5, step: 0.01, 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 whose bound drops from 5 to 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 neither core package declares a dependency on a section's package, so under pnpm such an import does not resolve.

The panel then draws the section's preferences in the same list as the theme 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.

Declare, provide, compose

A section is drawn the way Ark draws anything: data, state and context, parts.

where it livesfor the graph
What may be set, and whenthe manifest — data, with whenGRAPH_SECTION
The valuesKanzoThemeProvider — the one chain belowregistered by the host
What only the owner knows — the lists its choices name, the pictures its options weara SectionProvider over the owner's subtreeGraphRoot renders one, with its graph's columns
How it is drawn<Pref name>, or PreferencesSections for the whole sectionthe app's settings tab
// The owner says what only it knows — GraphRoot does this inside itself.
<SectionProvider namespace="graph" sources={{ columns, "numeric-columns": numeric }} specimens={{ marks, edges, placement }}>
  {/* …and every surface beneath draws the whole control */}
  <PreferencesSections namespace="graph" />
</SectionProvider>

A list is named within its section — { from: "columns" } is the graph's own, answered in the owner's sources (a PrefSources, name to options) — and the theme provider is the core's owner, answering "themes" from what the tenant published. Outside every owner a list is unanswered: the value a person stored stands, and the control it fills is not drawn. The owner judges a stored value against its list, where the list is known: the graph's root reads a column its graph lacks as unbound.

Offered only beside another: when

"additive-links": { kind: "toggle", default: "false", doc: "…", when: { pref: "edges", neq: "hidden" } },

One sibling, eq one value or neq one value — Storybook's argTypes.if, an object a tenant can read rather than an expression anything has to parse. It decides what is drawn, never what resolves: a preference out of view keeps its stored value, so going back to the map finds the columns where the reader left them. The condition's type is PrefWhen, and prefShown(decl, values) is its one reader.

Compose the parts

<PreferencesSections namespace="graph" />   // the whole section, in the manifest's order

<Fieldset legend="Placement">               // or the parts, in the page's
  <Pref name="graph.placement" />
  <Pref name="graph.x-by" />                 {/* only under Map */}
  <Pref name="graph.y-by" />
</Fieldset>

const placement = usePref("graph.placement"); // or a control of your own

Preferences has the parts' API, and Preference or theme why the shape is Ark's.

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" }, appearance: { pinned: "dark" } },
    graph: { marks: { pinned: "legible" } },
  }}
>

So a client ships our product is dark and compact as the point their users start from, and a user preference is an override on top of it. Two limits: density is declared personal, so a tenant may start it and may not pin or withhold it; and the brand's shape — radius, typography — is never a policy, because it is authored in the client's theme.

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. Each returns a list of SectionProblem — { token, kind, detail }, kind being "unknown" or "shadowed" — named for the section it judges, because Problem is the component that draws a failure.

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