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:
| half | what it is | who decides it |
|---|---|---|
tokens | colours in the package's own namespace — --graph-grid | 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 = {
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.
| kind | what it declares | drawn as |
|---|---|---|
choice | options, a closed list of { value, label } | a row of radio cards |
choice | options: { from }, a list its owner answers | a Select |
toggle | nothing more | a switch |
range | min, max, step | a 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
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 lives | for the graph | |
|---|---|---|
| What may be set, and when | the manifest — data, with when | GRAPH_SECTION |
| The values | KanzoThemeProvider — the one chain below | registered by the host |
| What only the owner knows — the lists its choices name, the pictures its options wear | a SectionProvider over the owner's subtree | GraphRoot renders one, with its graph's columns |
| How it is drawn | <Pref name>, or PreferencesSections for the whole section | the 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 ownPreferences 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.