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:
| Export | metadata-form's call site |
|---|---|
LanguagePicker | src/react/widgets/defaultWidgets.tsx, the rdf:langString language-tag widget |
MadeWith | src/react/form/MetadataForm.tsx, the form's attribution line |
FieldArray | src/react/form/FieldRenderer.tsx, a repeated field |
useAsyncCollection | src/react/fieldassist/Comboboxes.tsx, a reference combobox's candidates |
PreferencesRoot, PreferencesTrigger, PreferencesPanel, PreferencesField, PreferencesSections | playground/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.
Relations
Why JoinGraph is the one read-model of a catalog, why a typed CatalogReader is the only way one is made, how a mapping fills what a catalog does not say and where that match is fragile, why a path reads as Malloy's dots and SPARQL's inverse, and why the shape before a run comes from the compiler; and what would reverse each rule.
The references, and who wins
The case law under one sentence — Ark owns behaviour, Shark owns surface, AI Elements is a source and not a reference, and a measurement overrules all of them.