Kanzo UI
The design of Kanzo UI

What earns a name

The rules that decide whether something becomes a component at all — the taxonomy test, the second call site, the layers that ship code and the ones that ship a guide.

The admission rules say what may enter @kanzo-tech/ui. This page is the case law: the tests that have actually decided arguments, and what would decide them the other way.

A machine with a switch is a variant; a new content contract is a composite

The test for should this be a new component?, in one line: a machine with a switch → a variant or a mode; a new content contract assembled on a primitive → a PascalCase composite.

Every taxonomy argument in this library reduces to it. AlertDialog is Dialog with role="alertdialog" plus a convention layer — one machine, one switch — which is the same shape as a context menu against Menu, and why neither is a separate primitive. Preferences spans structure, a content contract and behaviour over the dialog, field and radio-group parts, so it is a composite and not Dialog variant="preferences".

Ark models alertdialog as a role, not as a machine. Radix separates them because there they genuinely are two primitives; copying that split here would fork us from our own behaviour library for no gain.

What would reverse it: nothing found in five rounds of applying it. Where this and the single-axis rule disagree, they have always disagreed about a component that should not have existed.

Held by packages/ui/src/simples/alert-dialog.tsx, which is literally Dialog with a role; packages/ui/src/index.test.ts, the ContextMenu* tombstones.

Adopt before extending

A family whose existing parts have no renderer does not get a new part. The highest-value move on an unused model is a call site, not a design round. field.tsx shipped its whole anatomy with no consumer while four rounds of design went into one more part.

What would reverse it: nothing yet — but the worked example closed, and the rule did not. field has no consumer was a record of its own until Field gained renderers: the family is now imported across the docs site and by modules inside packages/ui/src, and every labelled machine takes FieldLabel from it. Two parts remain without one, FieldSeparator and useField. The two adoption examples that closed it the other way — ChartStat and ChartColorLegend, each with a doc block, a test and a documented reason to exist and no renderer at all — are what the rule looks like when it is applied.

Held by packages/ui/src/index.test.ts, the pinned surface and the tombstones !TextField, !NumberField, !DateField; packages/ui/src/simples/field.test.tsx.

An export needs a second call site, and one example is not it

An export whose only consumer is one docs/examples/<slug>/ directory has not met admission rule 2. Delete the export. Keep the symbol when its own root renders it. An example is the page proving the part exists, not a consumer choosing it.

The care this needs is the whole finding. A naive sweep breaks components: an export can have no export consumer purely because its own root renders it — SheetOverlay, SheetPositioner, TourPositioner, TourOverlay, TourSpotlight, ClipboardIndicator. And a recipe can read as "used only by its own file" while another module imports it in type position: statusVariants is imported by avatar.tsx for VariantProps<typeof statusVariants>, which still requires the value to be exported.

ProgressTrack was held up as the ideal version of this shape — the part a consumer never places. It was not, and the way it failed is the more useful lesson. Progress renders the track unconditionally, so a consumer who followed the export got two troughs; confirmed independently twice, the export was deleted on that observation. It is exported again today, because Shark's own progress.tsx renders the same two parts in the same place and exports both anyway. The observation was right and the conclusion was not: a doubled part is evidence about a composition, and this rule is about an export.

So this rule has no worked example of its own left standing, and that is the honest state of it. What it governs is every name that is ours alone, where the reference is silent — SuggestItem and usePinInput are the two decided that way, and a reader looking for a case to copy should take one of those rather than the track.

What would reverse it: a second independent call site. One carve-out, which is not a reversal: the reference governs the surface, so the names Shark's registry exports ship without a call site of their own.

Held by packages/ui/src/index.test.ts, the pinned set and the tombstones, !SuggestItem and !usePinInput — the two names this rule decided where the reference is silent.

A layout tree is children, never an attribute

No prop whose value is markup. A record or an array of ReactNodes is a layout tree written as an attribute, and a caller handed one cannot reorder the regions, wrap one, spread className / data-* / aria-* / a handler onto one, or use asChild on one. CardHeader, not <Card header={…} />.

CardRadioGroup was deleted for it. A card radio is RadioGroupCard, and the grid it owned became columns on RadioGroup, written to a --columns custom property so a caller can override it per breakpoint — which an attribute could not.

What would reverse it: a compound whose arrangement is genuinely closed and which no caller has ever needed to reorder. Three attempts have not found one. The exemption that is real is a collection a machine navigates — Ark's createListCollection cannot be built from children — so FacetFilter's items, Tour's steps and a faceted column's values stay props.

Held by packages/ui/src/composites/SidebarIdentity.tsx, the doc comment on SidebarIdentity, which is the canonical statement; packages/ui/src/index.test.ts, the CardRadioGroup tombstone and !SidebarNav and !InstanceSwitcher, two of the three composites deleted for taking one (MadeWith was the third and is back on its own page, re-admitted with the brand as children — which is this rule being followed, not waived); packages/ui/src/simples/FieldArray.tsx, children, which is the sanctioned alternative when you want the ergonomics of a list.

One dashboard number, one compound

A headline figure is the Stat family — StatRoot, a Card carrying a status family, and StatIndicator, StatLabel, StatValue, StatDelta, StatTrend and StatDescription inside it. There is no second form for the plain case and no props convenience over the parts.

There were two, and they were one idea drawn twice. StatTile took label, value, delta and trend as props and shipped in the barrel; MetricCard was a Card with a status tint, a whole-card link, a skeleton and an icon disc, and lived in a showcase. The first was closed, so the first host that wanted a link or a status on a dashboard copied the second. Every part either had was already the other's missing piece: the link was Card's asChild, the tint a data attribute, the delta and the sparkline were StatTile's own. The collapse invented nothing, which is how you tell two components that were one.

The parts own the vocabulary; the host owns the policy. StatValue renders what it is given — FormatNumber with notation="compact" is the dashboard reading, and the threshold at which a count should compact is a decision about the reader, not about the tile. StatDelta is the exception that proves it: its sign is its meaning, so it formats its own magnitude, and the one policy it cannot infer — whether a rise is good — is goodWhenUp. The status vocabulary is the five families Badge, Alert and Status already spell as variant, not a sixth.

What would reverse it: a stat whose arrangement a caller cannot reach through the parts — a region that must render and that no host ever reorders — which is the condition a layout tree sets for a props form, and which StatTile never met: its one host outside the repo needed exactly the arrangement it withheld.

Held by packages/ui/src/simples/stat.test.tsx, "becomes the anchor under asChild, so a linked tile is one link and not a link around a card"; packages/ui/src/index.test.ts, !StatTile and !MetricCard.

A compound keeps its root, even when the root is an alias

Sheet stays, as (props) => <Dialog {...props} />, and MenuSub stays as the same over Menu. A root that renders nothing of its own is not a cut, when the family below it is real.

The root is the family's name, not a component in its own right. Ark's dialog and menu roots render providers and no element, so once their dead data-slot came off there was nothing left in the body — but the thing that makes a sheet a sheet lives in SheetContent, and deleting the alias would force <Dialog><SheetContent /></Dialog>, which reads as a mistake and breaks the naming rule's own promise that a compound is greppable as one vocabulary.

Written down because a minimality sweep will find these again and the export census already lists them as thin renames. The census is right about the shape and wrong about the conclusion, and the difference is one question: is the root the only thing that is thin, or is the family? An export with no body is evidence, not a verdict.

What would reverse it: the family below the root ceasing to be distinct. That is the real test, and it has been applied in the other direction — ContextMenu was cut, because its parts were each a data-slot rename of Menu's with no appearance of their own, and the one genuine difference survived as MenuContextTrigger.

Held by packages/ui/src/simples/sheet.tsx, SheetContent; packages/ui/src/simples/menu.tsx, MenuSubContent and MenuSubTrigger; packages/ui/src/index.test.ts, the ContextMenu* tombstones, which are the contrast.

The zero state is a compound of parts, and EmptyState stays dead

Empty ships as six parts, EmptyRoot through EmptyContent. EmptyState is still a tombstone: it was four ReactNode props over a raw div, and a layout tree written as props is still refused. What was wrong was the replacement. A vertical Item carried role="listitem" outside any list, and centring its media needed group-has-data-[slot=item-description]/item:self-center, an override keyed on another part's internal slot that breaks when that slot is renamed. The showcases wrote it that way, and a consumer repeated the arrangement on six pages. An arrangement that needs an override to look right is not a composition of the parts it borrows.

Ark ships no empty state, so the anatomy is shadcn v4's Empty, and the names follow the house rule and Ark. EmptyRoot, not Empty, because it is a compound of ours. EmptyIndicator, not EmptyMedia, because Ark calls the glyph a part shows an indicator. EmptyContent stays shadcn's name because it holds more than actions: a link or a search field as often as a button. EmptyHeader stays a part. The root's gap-6 separates the text from what follows it, the title and its sentence need gap-2, and both blocks need the max-w-sm measure. Without the part, one flex column would need two gaps, which is margins on the children.

What would reverse it: the consumer's call sites turning out to be one arrangement that a single showcase could hold, or a real empty state that needs something the parts cannot express and gets it only from a prop. Either one sends it back to being an example.

Held by packages/ui/src/layouts/empty.test.tsx, "declares no list semantics and no role on any part"; packages/ui/src/index.test.ts, "drops the pre-arrangements over parts that already ship", !EmptyState.

@kanzo-tech/ai had a zero state of its own, and it is gone. ConversationEmpty was a centred flex-1 column with a max-w-md measure, muted small text and a bare svg sized to 6×6: EmptyRoot, EmptyHeader, EmptyDescription and EmptyIndicator under a second name, and nothing a transcript knows that a panel does not. Composing it from the parts would have kept the name and added nothing to it, so it was deleted rather than rebuilt. The zero state of a conversation is whatever the host hands Chat as empty, and the Empty parts are what it reaches for. A sibling package does not get its own copy of a ui idea because the idea happens to appear inside one of its components.

What would reverse it: a conversation's zero state needing something only the transcript knows — the pin, the stream, the turns — that a node handed in from outside cannot be told.

Held by packages/ai/src/conversation.test.tsx, "names its parts, and the zero state is ui's EmptyRoot rather than a part of its own".

A role written out four times is a part: Eyebrow

Eyebrow is the small, muted, uppercase line that names a block from above it, and it is the one piece of type in the library besides Prose. Prose says the position it is an exception to: components style their own text, and the Heading and Text atoms went because they were a scale of knobs with no consumer.

This is neither. It was the same five utilities typed out by hand four times — LABEL in @kanzo-tech/ai's Tool, PREF_HEADING in Preferences, the archive's heading in the discovery showcase and the settings column in settings — with a fifth in keasy's guided chat, which is what asked. One role, no knobs, five call sites: that passes the second admission rule without the example directory counting.

A part, not a recipe. Every call site needed the look on an element its own machine dictates — a <legend>, a <label>, SliderLabel, a heading — which is why the preferences panel had kept it as a string. asChild answers that the way Link does, and an exported class string is the thing a class list is not API refuses: the reference ships no such recipe, so the house rule decides.

Named by the typographer's term. Shark and Ark have no name for it, and the two candidates in print are eyebrow and Material's overline. The second is also Tailwind's overline, a text decoration, which a class list beside it would read as. One size, text-xs. Preferences is set at the IDE tier's 11px throughout, so it passes that size through className rather than the part growing a size for one panel.

What would reverse it: a second fixed size with two call sites of its own, which is a size variant; or the call sites dwindling to one, which is the inline string again. A request for a weight or a colour is the Text atom coming back, and is refused on the same grounds it went.

Held by packages/ui/src/simples/eyebrow.test.tsx, "lands on the element it heads under asChild, and a part keeps its own slot"; packages/ui/src/composites/Preferences.tsx, PREF_HEADING; packages/ai/src/tool.tsx, Eyebrow.

A status with a measure carries its bar: Working's value

@kanzo-tech/ai's Working draws @kanzo-tech/ui's Progress under its words when it is given a value, and none when it is not. It was the other way: the bar was Progress placed beside it, on the grounds that a status without one is the common case. Both of keasy's measured steps — reading the files and building the graph — then wrote the same wrapper around the pair, and the guided example wrote it a third time; that is a composition, not a coincidence.

The prop is Ark's progress machine's own: number | null, null indeterminate. Progress spells the same thing as value and indeterminate, which leaves no room for the third state this needs — no bar — and the machine's spelling does: undefined. A compound WorkingProgress part was the other shape, and it would place a bar nobody can place anywhere but under the words.

What would reverse it: a host placing the bar somewhere other than under the words — beside them, or across a panel — which would make the bar a part again.

Held by packages/ai/src/working.test.tsx, "draws a bar under the words given a value, indeterminate given null".

A gate is a compound, because the composition was wrong about what it hid

Gated ships as three parts: GatedRoot, GatedContent, GatedBadge. Ribbon stays a tombstone — a positioned box, a Float and a Badge behind scalar props, shorter than the composition and no more capable. What changed is that the composition turned out to be wrong where it was written out. The app-shell showcase and the Badge page both said inert "leaves it readable to a screen reader", and it does not: inert content is withheld from the accessibility tree, like aria-hidden. The only thing left to read was a badge saying Coming soon about nothing a reader could perceive. A consumer builds the same gate for a connection location that is not available yet, so it had a second call site outside the showcases.

That is the naming rule's own test passed, not waived: the parts are more capable than the composition, because the root is a group the badge describes and the caller names, so the reason is announced where the region was. The muted treatment is opacity-64, disabled's own, and the pointer-events-none the showcase carried is dropped because inert already hit-tests that way. No data-gated: nothing selects on it, and data-slot="gated" already marks the root for a caller.

A disabled control is not gated. job-studio's two Coming soon radios stay a Float and a Badge beside a disabled card, because an inert radio would leave its group rather than be announced as unavailable.

What would reverse it: a browser exposing inert content to assistive tech after all, which puts the gate back to being an attribute; or a gate whose reason cannot be a badge — a sentence, a link — which would want the reason as its own part rather than a Badge with an id.

Held by packages/ui/src/layouts/gated.test.tsx, "names the group after the feature and describes it with the badge"; packages/ui/src/index.test.ts, "drops the pre-arrangements over parts that already ship", !Ribbon.

A grammar ships its whole vocabulary

The chart mark and interactor wrappers stay, and stay complete, even where no example draws one. This is one of the two standing exceptions to admission rule 2 — the other is the names the reference ships — and it covers /analytics only: one-line descriptors over somebody else's grammar, and the re-exported aggregate vocabulary beside them.

A vocabulary with holes sends the author to @uwdata for the one aggregate we left out, which is exactly the import this barrel exists to remove — and a mark you write yourself is a mark you write wrong. Curating a grammar costs more than cataloguing it.

The six marks withheld on purpose are part of the same decision, and they are the shape of its limit: axes here compile to plot attributes, so an axis mark would steal the binding from the interactor after it. A vocabulary is complete where completeness is free and withheld where it breaks the layer.

What would reverse it: the wrappers acquiring bodies. The exception is priced on their being one line each; a wrapper that grows logic is a component again and answers to the admission rules like everything else. It is also reversed by the grammar itself shrinking — a closed, named set is re-exported, an open one stays a direct import.

Held by packages/ui/src/analytics.ts, the notes on the aggregate exports and on the six withheld axis marks.

Charts and the data table ship code; forms ship a guide

Mosaic and TanStack Table get a thin layer of our own — /analytics, /table. Validation gets a documented pattern and no layer.

The two engines have one obvious binding each, and the whole cost is wiring it correctly once. Validation has no obvious binding, because our own consumers validate in ways with almost nothing in common, so any model rich enough for both would be shaped by whichever shouted loudest.

The corollary that shaped both layers: a mark cannot be an Ark part, because vgplot replaces the host's children wholesale and there is no DOM for a part to own. That is why /analytics is descriptors and a root rather than a compound.

What would reverse it: a second validation consumer that agrees with the first about keys, severity and cardinality. Two have not.

Held by packages/ui/src/table.ts and packages/ui/src/analytics.ts, the two layers that were built.

A chart needs no factory

The analytics layer stops at ChartRoot and useChartContext(). There is no useChart(props) → api and no ChartRootProvider, and the as prop stays the way a caller owns the selection a root would otherwise mint.

@kanzo-tech/graph has Ark's four pieces — useGraph, GraphRoot, GraphRootProvider, useGraphContext — and the obvious next move was to do it again here for symmetry. The question that stopped it: what would useChart(props) actually return, given how much of a chart's state lives in Mosaic's coordinator rather than in an object of ours? The answer is ChartContextValue, which already exists and is already published. That is not a factory; it is the context with a second way to reach it.

What forces the factory in the graph is specific: its state lives outside React — the graph loads, a layout ticks and a hover moves at frame rate — so a store of ours owns it, and useGraph is what creates that store and subscribes to it (the graph view). Nothing in analytics has that shape — useChartQuery and useChartCapacity each read useMosaic(), which is the provider above the root rather than the root itself, so there is no circularity to break.

Three things here look like state and are not. The compiled spec is a pure function of children, and children is a render input — a factory taking props could not see it without being handed it, at which point the factory is the render. The context is entirely derived; color reads a token off the live host element at call time, and the DOM is below. The host ref is kept so a scoped theme override wins when a token resolves, and never published. The one thing a root does mint is its own Selection.union(), which ChartHighlight reads, and the escape hatch for owning it externally already exists: ChartHighlight's by, and an interactor's as.

What would reverse it: a hook that must be called beside a ChartRoot and needs a value only the root owns. That is the exact shape that forced useGraph, and it would force this.

Held by packages/ui/src/charts/chart-root.test.tsx, "exposes the series colour and a locale number formatter through useChartContext" and "filters by and publishes into the provider's one selection, a crossfilter".

The AI surfaces are their own package

@kanzo-tech/ai ships two capabilities — a field the model helps fill (Assist) and a conversation with one (Chat). It depends on @kanzo-tech/ui; nothing in ui may import it. The root barrel is a one-way door, and a consumer who wants a Button must not pay for a transcript.

The line is not is it AI. It is does the component know a model exists. It used to have an exception: the two field affordances took a function returning an async iterable, never made a request, and by that test should have stayed in ui; they moved on the owner's call, because the ✨ marks a field as model-assisted to the reader and that mark was the thing being bought. The exception is gone. Assist takes a LanguageModel and writes the prompt itself, so it knows a model exists by the plainest reading, and the line holds without a carve-out.

@kanzo-tech/ui/editor's CodeEditor was checked before the first move and imports nothing from the assist engine — the only completion in that file is CodeMirror's own autocompletion and completionKeymap. That is what makes the split produce no cycle; had it imported the engine, ui/editor would have depended on ai, and ai already depends on ui.

An earlier draft of that paragraph said CodeEditor "takes a complete prop", and it does not: CodeEditorProps is value / onChange / extensions / readOnly / basics. The claim came from editor.ts's own comment and from a row in forms/controls.mdx, both describing a design that was never shipped. The conclusion survived its premise — which is the shape a citation that reaches outward always takes.

What would reverse it: a second package needing the proposal engine without the surfaces, which would make the engine public in a third package rather than move it back.

Held by packages/ui/src/index.test.ts, "drops components superseded by composition or a merge", !SuggestRoot, !CompleteRoot, !useAiStream.

AI assist wraps the pure inputs

Assist wraps a Textarea, an Input or a TagsInput and wires it from outside — the value, the keys, the ✨ — with one AssistProvider above the form. No complete prop on the primitives, and no field context inside ui: the control Assist wraps is byte-identical to the one a form without a model uses.

A prop on Input welds the model into the primitive: the core stops being Shark-verbatim and imports the engine. That half of the rule has held through four designs — a prop, a provider inside ui, two compounds over asChild with two headless hooks, and this one.

The other half was no provider, gated on "a real second consumer with shared AI state", and the gate opened. Every assisted field in a form asks the same model, is told the same thing about the form, reports to the same telemetry and speaks the same language. The metadata form has three such fields, and every form with a second assisted field is the same case; the two compounds made each field restate all four. So the provider holds exactly those four and nothing per field; it renders no element. The engine is internal: the headless hooks had one caller each, both in this package.

What would reverse it: a field that needs a different model, context or language from the form around it often enough that nesting a second provider is the usual case rather than the exception.

Held by packages/ai/src/assist.tsx, AssistProvider; packages/ui/src/index.test.ts, the AiAssist / FieldSuggest / useAiField tombstones.

A tool panel composes its snippet; the house ships no third code chrome

@kanzo-tech/ai ships no code-snippet component and imports nothing from @codemirror/*. A host that wants a tool's statement highlighted draws it in its own renderer for that tool — <Chat tools={{ query: (part) => … }}> — composing CodeEditor from @kanzo-tech/ui/editor as a readOnly child; a host that wants only the chrome wraps the block in Prose, which already carries it.

The house has code chrome twice already, and a third would be a third spelling: the typography plugin's --tw-prose-pre-bg / --tw-prose-pre-code under .kanzo-prose, and the whole tokenised CodeMirror theme on /editor. And the per-tool renderer exists for exactly this — the payload is the host's to draw, and JSON is only the default for when it has nothing better — so a snippet component in ai would answer a question Chat already answers.

Two of the three premises behind the original request did not survive being checked. The blocked import is only blocked for ai, not for a host: the renderer is the host's code, so it composes <CodeEditor readOnly extensions={[sql()]} /> and ai imports nothing — the optional-peer door stays shut because the host opened it, which is what a subpath is for. And there is no highlighting gap: /editor carries kanzoHighlightStyle, and a readOnly CodeEditor is a legal way to draw a snippet. Nobody had tried it.

There is a chrome gap and it is smaller than it looks. Exactly one site invented chrome — a hand-written <pre> in what was the tool example — and by the rule above an examples directory is not a call site at all. A Snippet in the root barrel would enter on zero proven demand, against a barrel with barely half a kilobyte of headroom.

What this cannot settle: whether Prose is the right wrapper. It is only not yet needed — the one site that had the problem wanted highlighting, so it took CodeEditor and the question never came up. The day a caller wants the chrome without a language, this has nothing to offer them but a max-w-[65ch] container built for something else.

What would reverse it: a second in-repo call site that needs a static, unhighlighted snippet and cannot reach Prose, or a measurement showing that composing CodeEditor for a five-line SQL statement costs a consumer more than a purpose-built block would. A host's Ask panel is the one outside consumer; if it lands on inventing its own <pre> too, that is the second call site.

Held by packages/ai/src/chat.tsx, ChatToolRenderers; packages/ai/src/tool.tsx, JsonTreeView; packages/ui/src/tailwind.css, .kanzo-prose.

A failure is read by shape, and worded by the host

Problem is the one composite that reads a thrown value. It enters on two call sites that each wrote the reading by hand — a host's error view, over Diagnostic's parts, and the graph docs' failure overlay, a code lookup over an Alert — and it reads a shape, not a class: code in area/kind, title, message, data, cause, and severity, help and related where they are present. The library is to throw one class, the kernel's Problem, and the four it throws today are to go (to be built; the tree wins until then), and the shape is still what is read: fossil's FossilError, a host's ApiError and a Problem rebuilt from a worker's message are other objects of the same shape, so the composite imports no class and tests with isCode. That is what lets one component show all of them, and one more nobody has written yet. The composite and the class share a name; the kernel page says how a file that needs both spells it.

The words are the host's, through one function. describe(code, data) returns a title, a detail, a link and the page that explains the code, or nothing. It is not a translation — it is the host's wording of one failure it knows, resolved from that failure's data — so it is not translations, which stays the record of Problem's own fixed strings (one prop translates); it was copy, and took the name Findings already gave the same kind of function, the host's reading of a thing it owns. A registry of codes inside the library would name products — the codes worth rewording are the host's and fossil's — and a provider would be a second way to pass what LanguagePicker's translations already passes as a prop. A rewritten detail keeps the author's row one level down: the host may say it differently, never instead.

What would reverse it: a thrown shape the library's own packages raise that this cannot read — a cause that is not cause, a code outside area/kind — which would mean the shape is not shared, and the reading belongs back with each host.

Held by packages/ui/src/composites/problem.test.tsx, "takes the title, detail and link from describe, given the code and its data, and keeps the author's row below", "is named by its row, so six problems are not six buttons called Details".

The structure view is TreeView until the data nests

The library ships no schema display. A structure — a shape's fields, a relation's columns, a module's declarations — is rendered with TreeView where it nests and DataList where it does not. This declines AI Elements' SchemaDisplay, assistant-ui's spec sheet and the Declaration an AI-layer memo gated, which are one question under three names.

Neither shape document we ship has recursive data, so a tree buys nothing over a labelled list. The boundary object exists and it is flat: field-notes' Column is what a real SHACL parse produces here — a key, an IRI, a label, an order, a type, a pattern, an option list and a required flag, one sh: characteristic each — and it survives the round trip to both other dialects intact, compiling into JSON Schema for a vision model and into one prose line per field. discovery's Relation is the same thing without the constraints. Re-derive the nesting claim with grep -cE 'sh:(node|or|xone|and|not) ' docs/showcases/field-notes/shape.ts — the trailing space matters, because a bare sh:or false-matches sh:order.

The case that does not fit: discovery's Schema is a hand-rolled schema display. That is one renderer where the rule above asks for two, and it is visibly below what a component could give — discovery/data.ts types its fields as strings with the name and the type crammed into one, so a component could not render them apart without that call site being rewritten first. Two showcases that look like they should count do not: metadata-form holds no shape at all, and field-notes shows its shape as source, in a CodeEditor, editable, so that the panel and the engine cannot drift apart. A display cannot be an editor, so replacing it would mean overturning a stated design reason to manufacture the missing call site.

What would reverse it: a shape that nests — sh:node, sh:or, sh:xone — or a second renderer for the flat case. fossil's outline gaining nesting and a type is the same condition reached from the other side.

Held by docs/showcases/field-notes/rudof.ts, Column; docs/showcases/discovery/data.ts, Relation; packages/ui/src/simples/tree-view.tsx, TreeViewItem.

A question is one part, in @kanzo-tech/ui

A question put to a person is one compound in @kanzo-tech/ui — Question: a FieldSet, its prompt, its description, its choices or its input, and its error — usable on its own; Questionnaire is the sequence and the navigation around several, and @kanzo-tech/ai has no question of its own. Decided 13 October 2026. There were two: Questionnaire's item, which could not render outside a questionnaire, and ai's Question, a radio group of outline buttons that keasy's assistant draws a decision with. A guided chat asks one question per turn and a questionnaire asks several in a row; the question is the same part, and two of it was one idea in two shapes.

It belongs in ui because it does not know a model exists, which is the line between the two packages: a decision's options are data, and drawing them is a form. Its parts are Question, QuestionPrompt, QuestionDescription, QuestionChoices and QuestionChoice, QuestionInput, QuestionTextarea and QuestionError — the names ai's parts held, freed when they went and confirmed by the owner — and Questionnaire keeps its root, QuestionnaireProgress and the actions. The look is the one Questionnaire already drew, a card per choice, not ai's outline buttons: there is one look for a choice among sentences, and the card is the one whose whole surface is the target.

Alone, the answer is the question's — value, defaultValue, onValueChange, as Ark's controls hold theirs — and a pick is the answer in one press. Inside a Questionnaire it is the questionnaire's, filed under the question's name, so a resumed session is still defaultAnswers and defaultStep. The words came under one prop in the same change: Question's unanswered, the error's sentence, and the questionnaire's progress sentence, the actions' name and the four buttons, on Questionnaire's translations, which reach its questions.

Its call sites are the guided chat — keasy's assistant draws a decision with it, and Ask's decision is the next — and Questionnaire. That is what admits Questionnaire, which had no product call site of its own.

What would reverse it: a question in the chat that a form cannot draw — a choice among charts drawn as charts. Then the choice's content is the host's, passed as children, and the part is the same.

Held by packages/ui/src/index.test.ts, "exposes one question part, and a Questionnaire that is only the sequence around it", !QuestionnaireItem, !QuestionnaireChoice; packages/ui/src/composites/question.test.tsx, "is a radio group named by its prompt, a choice per radio, outside any questionnaire"; packages/ui/src/composites/Questionnaire.test.tsx, "says every word in the root's translations, and a question's own win for that question"; packages/ui/src/one-meaning.test.ts, "gives each name one meaning across every entry, and shadows none of the language's".

A composite stays where a product calls it

LanguagePicker, MadeWith, FieldArray, useAsyncCollection and the preferences panel's parts stay in the root, because a product calls them: Kanzo-Tech/metadata-form, on @kanzo-tech/ui 0.27. With each one's docs example, that is the second call site the rule above asks for — recorded here because an audit of this repository and keasy alone, on 13 October 2026, found none of them used:

Exportmetadata-form's call site
LanguagePickersrc/react/widgets/defaultWidgets.tsx, the rdf:langString language-tag widget
MadeWithsrc/react/form/MetadataForm.tsx, the form's attribution line
FieldArraysrc/react/form/FieldRenderer.tsx, a repeated field
useAsyncCollectionsrc/react/fieldassist/Comboboxes.tsx, a reference combobox's candidates
PreferencesRoot, PreferencesTrigger, PreferencesPanel, PreferencesField, PreferencesSectionsplayground/src/App.tsx and playground/src/Preferences.tsx

ThemeNotice, FloatingPanel and the Preferences all-in-one left the barrel for docs examples. The all-in-one was a fixed arrangement of PreferencesRoot, PreferencesTrigger and PreferencesPanel, which metadata-form composes itself, so its parts stay and the arrangement is three lines on its page — the TextField case. The other two had no caller in any product. ThemeNotice was one effect over the provider's retiredTheme, and its own comment justified rendering nothing by pointing at a component the barrel no longer meant to keep; it is an example beside the hook it reads. FloatingPanel was a resizable div under the name of Ark's floating-panel machine, which Shark wraps as fifteen parts; it is an example, and the name is free for the machine. An example is the composition, copied by whoever needs it.

What would reverse it: metadata-form dropping one of the five. Then it leaves the barrel for an example, unless another product has called it meanwhile. For the three that left, a product calling one — with a second call site of its own.

Held by packages/ui/src/index.test.ts, "keeps the composites a product calls, and no others", !Preferences, !ThemeNotice, !FloatingPanel.

On this page