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:
| half | what it is | who decides it |
|---|---|---|
tokens | colours in the package's own namespace — --graph-marquee | the tenant, through their theme |
prefs | choices with names — how dense the marks are, whether links add | the 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:
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.
| kind | what it declares | drawn as |
|---|---|---|
choice | options, a closed list of { value, label } | a radio list |
toggle | nothing more | a switch |
range | min, max, step | a 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
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.