Names and slots
Who owns `data-slot`, what may never be keyed on it, and the two collisions that decided a rename.
Conventions has the spelling rules. This page has the four arguments that produced them, and what would say each is wrong.
A primitive owns its data-slot
data-slot is written after the rest spread, on every part. That ordering is the rule and the
whole of what the guard enforces: a caller cannot erase a slot by passing one. Re-slotting is a
declared slot?: string prop, spelled data-slot={slot ?? "<component>-<part>"}, and that is the
only way to do it. Most parts carry it; a part that writes a literal instead is one a caller cannot
rename, and nothing checks the spelling.
data-slot is not decoration and not a default: our own recipes select on it, so a caller who
happens to pass one silently deletes styling the component depends on — no error, no visible symptom,
and nothing to grep for. An escape hatch a caller can unhook is not one. But renaming a part is a
real need, and it was being served by the same accident that caused the erasure, so the two had to be
separated before either could be enforced.
This rule was refined by its own reversing condition, which is the format working. The first
version said the case that would reopen it was a consumer needing to re-slot a primitive as distinct
from erasing its slot, and that such a case would want an explicit affordance — "a second attribute,
or a slot prop the component composes with its own" — rather than the accident of spread order. It
then said none had been asked for. That was wrong within the day, and wrong in the most useful way:
moving the attribute past the spread broke every thin rename in the library at once, because
overriding the wrapped primitive's slot is the rename mechanism. Because the condition had
described the counter-example closely enough to recognise on sight, the rule was refined rather than
re-argued. A reversing condition is worth writing even when — especially when — you believe nothing
will meet it.
One trade, taken knowingly. slot is a real DOM attribute — shadow-DOM slotting — and we now
consume it rather than forward it. Nothing in this library or in docs/ uses shadow DOM, and the prop
needed no new type anywhere because React's HTMLAttributes already declares it, which is what made
the conversion free. A consumer who needs to slot one of our elements into a shadow root is the case
that would have to be answered, and none exists.
The evidence that made the ordering a house rule rather than a three-site bugfix: the great majority of
sites wrote the attribute before the spread, and one file had already discovered the problem and
reversed itself in place — packages/ui/src/simples/combobox.tsx, ComboboxTrigger, which writes its
slot past the spread and hands the same value down to the Button it renders under asChild, because
composed inside an InputGroupButton the merge injected the wrapper's slot and its own matched
nothing. A rule one file learns the hard way and the rest of the library contradicts is a house-style
decision, not a defect. ComboboxTrigger is also the exception to the paragraph above: its slot is a
literal, so it is the one trigger a caller cannot rename.
What would reverse it: a part whose slot has to vary on something the call site cannot name, which
a prop cannot express. The conversion tested this and it held — under asChild, a parent can no longer
name an element it does not render, because the child now writes its own slot after its own spread and
always wins. Three compositions hit it, and all three were expressible: the slot moves down to the
child as slot, which is the more honest shape anyway.
Held by packages/ui/src/data-slot.test.tsx, which is what can now tell a rename from an erasure;
packages/ui/src/simples/alert-dialog.tsx, the thin renames whose own recipes select the renamed
values.
What identifies a part is not what a caller may change
A measurement a component needs in order to work may not be keyed on data-slot. Control heights
live on each recipe's base as h-(--size), where the size variants only move a custom property:
[--size:calc(var(--size-field)*7)]. The density knob is untouched and still --size-field.
Appearance keyed on data-slot is fine and stays — losing a colour on a rename is the caller asking
for a different look, which is what the seam is for.
data-slot is two things at once and only one of them is stable. It is the seam a call site styles
against, and this house makes it renameable on purpose: every part takes slot?: string. A
measurement hung off it turns that seam into a load-bearing wall — exercise the rename and the control
silently stops being the size it declares, with no type error, no failing test and nothing to grep for.
It had already happened, to a control that renames a Button, and it landed under the pressable floor
this library adopted.
The measurements. Taken in a live page, one document, three controls off the same recipe:
data-slot="button" at sm rendered 28 px and at md 32 px, while conversation-scroll-button at
icon-sm rendered 16×16 where its size asks for 28. WCAG 2.5.8 states 24×24 CSS pixels, which is the
floor pressable-floor.test.ts adopted. Seven further icon-only renames sit on the same fault —
calendar's previous and next, pagination's previous, next and item, and file upload's item delete — all
found by grep after the first was measured, and none of them measured in a browser.
The placement is daisyUI's: their base rule declares the height and the size modifier only sets the
variable. We had already taken the density knob from them without taking the placement that makes it
safe. What the old placement was right about survives the move: control height is --size-field and
padding is Tailwind's --spacing, because a tenant asking for compact controls is not asking for
tighter text. That split is kept exactly. The old comment's own reasoning — "data-size is already on
the element, so this needs no new attribute" — was true about data-size and quietly untrue about the
data-slot beside it in the same selector.
What would reverse it: a measurement that genuinely has to vary per part rather than per size,
which a custom property on the base cannot express. The three families here did not — button, input
and native-select all used the same formula per size (sm ×7, md ×8, lg ×9), so the slot in
those fifteen selectors was carrying no information at all, only the fault.
Held by packages/ui/src/no-measurement-on-a-renameable-slot.test.ts, "is written nowhere";
packages/ui/src/simples/button.tsx, the h-(--size) on base.
A type and a component may not share a name across two barrels
@kanzo-tech/ai's Suggestion type is Candidate. @kanzo-tech/ui's Suggestion component
keeps the name, because it is the one Shark ships. No alias, no deprecation — nothing has been
published.
The two were in scope in the same file and one of them could not be written.
packages/ai/src/suggest.tsx draws the component while typing its data with the type, and it was
importing its own package's type under an alias to do it. An import that has to rename a symbol to
be usable is the collision announcing itself, and the alias was the workaround rather than the answer.
A consumer hits the same wall the first time they write both imports, with no file of ours to look at
for the local name that got chosen here. Asked which of two colliding names the reference governs:
Shark ships Suggestion as a component, so that half is not ours to move, and the type is.
The alias was hiding a better name. import type { Suggestion as Candidate } from "./types.js" sat
three lines under import { …, Suggestion, Suggestions } from "@kanzo-tech/ui". It compiled, it was
correct, and it had already chosen the word the rest of that file uses. The rename does not invent
anything; it promotes what the code was already saying in private. The type appeared in seven source
files, seven documentation files and two showcases — none of it difficult, and all of it invisible
until somebody writes both imports at once.
What would reverse it: Shark shipping a Suggestion type of its own, which would make Candidate
a divergence from the reference rather than a way of keeping it — or the component leaving ui, at
which point the collision is gone and the shorter name is free again.
Held by packages/ai/src/types.ts, Candidate; packages/ai/src/suggest.tsx, the import that no
longer aliases.
A hook takes the name of the request it makes
The ghost-text hook is useInlineCompletion, and its surface types
are InlineCompletion, InlineCompletionRequest, InlineCompletionTrigger and
UseInlineCompletionOptions. We had taken the semantics of textDocument/inlineCompletion and the
identifier of textDocument/completion.
LSP 3.18 ships the two as separate requests and the split is not cosmetic: the first returns a list the
editor presents and the user picks from, the second returns text drawn where the caret is and accepted
whole. Ours is the second, and the hook's own doc comments already said so — they cite
InlineCompletionTriggerKind for the trigger union, filterText for the rule that keeps an offer alive
while what has been typed agrees with it, and Monaco's inlineSuggest.mode: "prefix" for the same rule
under another name. Three citations to the inline request, under the identifier of the other one.
The AI SDK's useCompletion is a different hook with the same old name — it owns an HTTP endpoint and
the field's value, ours owns neither — and that collision is real, but it is not the argument. A
name is wrong because it names the wrong thing, not because somebody else took it. The collision note
belongs where a consumer is choosing between two imports, not on a page that would then be teaching the
wrong subject.
Complete did not move, and that is the case that does not fit. By the same argument it should be
InlineComplete. It collides with nothing — neither the AI SDK nor Shark ships a Complete — and the
rename is three times the diff for zero collisions solved and no clarity gained: a compound named
Complete over an Input, drawing a ghost, is not mistakable for a picker.
Complete / useInlineCompletion is deliberately not a pair, and the old spelling was not one
either. The house pattern is X / useX where the hook reads the compound's context —
useDataTableContext beside DataTable. This hook reads no context and is usable with any input;
Complete is one of its callers. The old Complete / useCompletion looked like the house pair and
never was, which is its own reason to break the resemblance.
What would reverse it: LSP collapsing the two requests into one, or a second hook here whose semantics are the plain request's — a list of candidates the caller picks from — which would then own the short name.
Held by packages/ai/src/index.ts, useInlineCompletion; packages/ui/src/index.test.ts, "drops
components superseded by composition or a merge", !useInlineCompletion;
packages/ui/src/documented-exports.test.ts, "documents no symbol the library does not export".
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.
The colour document
Why a theme is a flat block of CSS with one mode, what a section is, and the five arguments the refoundation retired along with the derivation that made them.