Box Kite 2.1.0
21 September 2026 · npm · Compare v2.0.1...v2.1.0
A model can compose the UI now, and the app still owns it. catalog() says what may be built, <SpecRenderer> renders what came back against the components your app allows, and an agent's turn — the tool call, the approval, the reasoning, the prose as it streams — has components of its own. The package ships an MCP server beside them, so the agent writing your code can ask the real engine what a prop does instead of guessing. And the data grid became a spreadsheet: a marked block of cells, Ctrl+C, Ctrl+V and an editor in every cell.
Everything here is additive, so nothing you have written has to change. One thing to know before upgrading: a theme block now generates an @scope rule, which needs Chrome 118+, Safari 17.4+ or Firefox 128+ — below that the theme is dropped silently.
Highlights
- What an AI may build, as JSON Schema —
catalog()describes every component and every value its props take, so a generated UI can be validated before it renders and cannot invent a colour. - What a model wrote, rendered safely —
<SpecRenderer>renders a generated spec against the components your app allows: unknown names render nothing, refused props are dropped, and only an event can become a function. - The whole loop, and the route that runs it —
specSchema()is on the catalog entry too, so a server route can build the constraint a model generates under; and a node missing a prop it cannot do without is held back rather than left to throw. - A dashboard people rearrange, and a model can write — a drag-and-resize widget grid whose layout is JSON in cells: every place is a class, the keyboard gets a grab, and a model can write the same file a drag reports.
- A generated dashboard with something in it — the catalog describes a grid's columns and a dashboard's layout now, so a spec can say where each widget goes and what is inside it.
- An agent's turn, in three components — a tool call, an approval and a chain of thought, typed and themed and 1.7 KB on top of Box: the inventory an AI feature needs at the layer this library is good at.
- What the agent says, as it arrives —
<StreamingText>fades in the part of a message that was not there a render ago, and costs the same at the ten-thousandth token as at the first. - Markdown, and the dependency we did not take —
markdownComponentsis thecomponentsmapreact-markdownand Streamdown both take, so a model's prose is themed with no stylesheet, no build-tool config and no parser chosen for you. - Where the answer will be —
<Skeleton>, the placeholder: bars with a gloss,aria-hiddenunless you name what is loading, and it renders on a server. - Four agent runtimes, one vocabulary —
@box-kite/react/interop: AI SDK, assistant-ui, CopilotKit, A2UI and AG-UI, each verified against its own published package. npx @box-kite/mcp: the answer a documentation file cannot give — an MCP server whosecheck_stylestool hands your props to the real engine, because a value this library does not accept writes no CSS at all and says nothing about it.- A block of cells, and Ctrl+C — drag or Shift+arrow across a DataGrid and copy it straight into a spreadsheet.
- Ctrl+V, one judgement per cell — paste a block back in, judged by the same
def.onCellEditan editor is. - A fling renders where you are going — the DataGrid keeps its rendered rows ahead of the scroll instead of on both sides of it: 36 rows around an 18-row screen where it used to be 58, and a fling at 125 frames a second where it was 81.
- A hundred thousand rows, and a page that measures them — the grid benchmark is public, reruns in your own browser, and says where this grid is quick and where it is not.
npx shadcn add @box-kite/data-grid— three finished sections the shadcn CLI installs into your own repository, from a registry on box-kite.dev.- The DataGrid exports its types —
ColumnType,GridDefinition,CellModeland the rest come offcomponents/dataGridnow instead of a path inside it. - A theme inside a theme — a local
<Box.Theme>finally wins inside a themed page: a theme reaches the subtree it owns and stops at the next element that declares one. - Theming now needs a 2023 browser — the rule a
themeblock generates is an@scopeblock, so below Chrome 118 / Safari 17.4 / Firefox 128 the theme is dropped and the unthemed values show. No API changed. - A site about this library, and an answer for the agent writing it — the three comparison pages are gone and the docs lead with what ships for an AI instead:
AGENTS.md, the whole prop reference and an MCP server, all inside the package. - /box, rebuilt for somebody who has never seen this library — what a Box is, the five things that are not what you would guess, a scale table the page measures live, and all 221 props searchable in one place.
/box-functionsis new beside it.
What an AI may build, as JSON Schema
There is a new entry point, @box-kite/react/catalog. It answers the question a generative-UI runtime
asks: which components may a model compose, and what may it put in their props? Everything else the package
ships for AI is read at development time by whatever writes your code. This is the runtime half — what a
model is allowed to build while your app is running.
import { catalog } from '@box-kite/react/catalog';
// The allow-list is yours: the library ships everything it can render, and the app says what it wants.
const allowed = catalog({ include: ['Flex', 'H2', 'P', 'Sparkline'], styleProps: ['d', 'gap', 'p', 'bgColor', 'fontSize'] });
allowed.components.Flex.props; // a strict JSON Schema, ready for a structured-output API
allowed.rules; // the dividers — what a schema cannot state and a prompt mustThe reason this library can answer it at all is that the props already are a constrained, serializable
design language: a Box tree and a JSON UI spec are the same thing written twice. So a colour prop comes out
as a pattern over the palette rather than a free string, and a generated tree that asks for #ff00ff fails
validation instead of painting an off-brand card. Every closed value list is an enum, every component
schema is additionalProperties: false, and a prop the registry genuinely leaves open is { type: 'string' }
with its listed values as examples — the catalog never states a constraint the library does not enforce.
Two sources meet in it, and neither would do alone. What a prop accepts is read off the live prop
registry when you call catalog(), so a prop or a colour added by Box.extend() is in the catalog with
nothing regenerated and no build step — call catalog() after the extend() that should be in it. What a
prop means is generated from the same JSDoc as the prop reference, because no registry entry knows that
fontSize divides by 16. What neither can say is in rules: the dividers, the millisecond times, the
unitless SVG lengths. Put those in the prompt — they are the mistakes that still validate and still render.
Getting it into a runtime is one adapter. json-render wants Zod, and
z.fromJSONSchema is the whole of it:
const jsonRender = schema.createCatalog({
actions: {},
components: Object.fromEntries(
Object.entries(allowed.components).map(([name, component]) => [
name,
{ props: z.fromJSONSchema(component.props), slots: component.slots, description: component.description, example: {} },
]),
),
});One thing to know if you use that runtime, measured against @json-render/react 0.20.0: its propsOf
schema type resolves to z.record(z.string(), z.unknown()) for any catalog holding more than one
component, so jsonRender.validate(spec) checks which components a spec names and lets any props through.
Each component's props is a self-contained schema for exactly this reason — check a node against its own
before rendering it. The same schema serves a structured-output API, and any other runtime that takes a
component catalog.
Function props are listed as events rather than described, since a JSON spec cannot carry a function and
the host is where binding one belongs. A ReactNode prop is a slot. A prop whose type a JSON spec cannot
express is left out rather than half-described.
What a model wrote, rendered safely
A second new entry, @box-kite/react/spec, and the other half of the catalog above. The catalog says what
a model may build; <SpecRenderer> renders what it built — against a registry the app puts together,
which is the same allow-list one step further on.
import { catalog } from '@box-kite/react/catalog';
import Flex from '@box-kite/react/components/flex';
import { H2, P } from '@box-kite/react/components/semantics';
import SpecRenderer, { createSpecRegistry } from '@box-kite/react/spec';
const allowed = catalog({ include: ['Flex', 'H2', 'P'], styleProps: ['d', 'gap', 'p', 'bgColor', 'fontSize'] });
const registry = createSpecRegistry({ catalog: allowed, components: { Flex, H2, P } });
<SpecRenderer spec={spec} registry={registry} data={data} onAction={(action) => run(action)} />;A node is { type, props, children, slots, on, repeat }, all of it JSON, and every field of it is checked
before anything renders. A type the registry does not hold renders nothing. A prop the component's own
schema refuses is dropped — the prop, not the node — so a colour has to be one the palette has and a value
still half-written mid-stream simply does not paint until it is whole. The only prop that can ever become a
function is one the catalog lists as an event: on: { onClick: 'refresh' } calls onAction('refresh', details), and what that means is the app's, which is human-in-the-loop by construction. There is no tag
that comes from the spec, no eval and no dangerouslySetInnerHTML anywhere in it.
A spec arrives in pieces, and that is the ordinary case. streamObject's partialObjectStream hands
over the same object a few more characters at a time, so a node whose type has not been written yet
renders nothing and reports nothing — it is a frame, not a fault. Every node also has an error boundary
of its own, so a component that throws on props a model invented costs that node and nothing around it,
and is tried again on the next frame.
Whatever did not render is reported rather than swallowed: onIssues gets a code (unknown-component,
invalid-prop, unknown-event, unresolved-data, too-deep, render-error…) and the path in the spec
it happened at. fallback is what stands where a node could not render, and renders nothing unless you
say otherwise — a red box is a poor thing to show somebody who did not write the page.
Data stays the app's. A model writes the shape of a view and the host owns the numbers, so a prop can
be { $data: 'stats.revenue' }, a child can be { $item: 'label' } inside a repeat, and a reference is
resolved before it is validated: the value the host supplied is the one the schema judges. A path, never
an expression.
And the other direction, so the constraint and the renderer cannot drift apart:
import { specSchema } from '@box-kite/react/spec';
const { partialObjectStream } = streamObject({ model, schema: z.fromJSONSchema(specSchema(registry)), prompt });specSchema(registry) is one JSON Schema for a whole tree, built from the same rules the renderer
enforces: the component names are an enum of what the app allowed, each one's props are its own schema,
and children is offered only where there is a slot to put them in.
The entry is 4.4 KB gzipped and carries no engine at all — the components come from the app, so nothing in
it imports Box. renderSpec(spec, options) is the same walk with no hook in it, returning
{ element, issues }, so a static spec renders on a server.
The whole loop, and the route that runs it
specSchema() is exported from @box-kite/react/catalog as well as from /spec, which is what makes
the server half of a generated UI writable at all: the model call is a route handler, and the entry that
renders a spec is a client entry. The catalog entry renders nothing, so a route can import it — and
specSchema() takes a catalog() as readily as a registry, so the server side needs no components.
import { anthropic } from '@ai-sdk/anthropic';
import { catalog, specSchema } from '@box-kite/react/catalog';
import { jsonSchema, streamObject } from 'ai';
const allowed = catalog({ include: ['DashboardGrid', 'Widget', 'Sparkline', 'DataGrid'] });
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamObject({
model: anthropic('claude-sonnet-5'),
schema: jsonSchema(specSchema(allowed, { bindings: true })),
prompt,
});
return result.toTextStreamResponse();
}The client half is <SpecRenderer> over whatever has arrived. It holds a node back now when a prop its
component cannot do without has not arrived yet, reporting a new missing-prop issue: a Sparkline with
no data reads undefined and throws, which its own error boundary caught — the same blank space, with a
caught crash per frame behind it. Rendering nothing is the same picture without the noise.
One more rule a stream imposes, now in the catalog's own descriptions: a generated node writes the
controlled prop and never the default… twin. React reads an uncontrolled default once, so the frame
in which defaultLayout first arrived whole is the one that sticks — and a second spec rendered in the
same place keeps the first one's state, since it is the same component instance. layout, value,
open: every frame re-applies them, and the last frame is what stays on screen.
box-kite.dev/generative-ui is the loop end to end, and
examples/next-app/app/generative is the live route, page and catalog in three files.
A dashboard people rearrange, and a model can write
There is a new component entry, @box-kite/react/components/dashboard, and it is two things:
<DashboardGrid>, a grid of widgets that can be dragged and resized, and <Widget>, the chrome around
whatever one of them shows. What passes between them is a layout — plain JSON in cells, with a version
in front of it — and that one artifact is what a model emits, what a drag reports back and what your app
stores.
import DashboardGrid, { Widget } from '@box-kite/react/components/dashboard';
<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable>
<Widget id="revenue" title="Revenue" description="Last 12 weeks">
<Sparkline data={revenue} variant="area" width="100%" height="100%" />
</Widget>
<Widget id="orders" title="Orders" onRefresh={reload} loading={pending}>
<Sparkline data={orders} width="100%" height="100%" />
</Widget>
</DashboardGrid>;Nothing is measured to lay it out. A widget's cell is grid-column and grid-row, which are props, so
they are shared classes: a dashboard of any size costs no transform per item, no ResizeObserver and no
inline style at rest — and the same layout renders on a server. The one inline style in the component is
the translate that keeps a dragged widget under the pointer, which is a value per frame and would be a rule
per frame that is never freed.
A drop takes the cell. A widget put on top of its neighbour keeps the cell it was dropped on, and the neighbour is handed one of its own — the row above where there is room for it, the row below otherwise. Everything then floats up, so a widget cannot be parked in mid-air, a drop below its neighbours rises to meet them, and two dashboards holding the same widgets in the same places compare equal.
Narrower is a projection, not a second layout. columns takes a count per container size —
{ xs: 1, md: 6, xxl: 12 } by default — and each narrower arrangement is the same layout scaled down by
arithmetic at render time, written as a container query. The browser picks between classes; nothing listens
for a resize. Two things fell out of building it, both measured in Chrome 153. A grid cannot
container-query itself — the query resolves against an ancestor container, so a track count per size
silently does nothing — which is why every projection is drawn on the widest arrangement's tracks. And an
arrangement can only be edited in the space it is written in: where the grid is showing a projection the
handles are not rendered at all, because an edit made in six columns is not a layout in twelve.
Dragging is not a keyboard gesture, so the keyboard gets a grab. Both handles are real buttons: Enter or
Space picks the widget up, the arrows move it a cell at a time — following the reading order, so ArrowLeft
moves it right in a right-to-left page — Enter drops it and Escape puts it back, the layout with it. Every
step is announced in a live region that exists before there is anything to say, and a grab that loses focus
is cancelled rather than dropped somewhere nobody looked at. The grid is a role="list" of widgets, each
one titled by a real heading at level.
A widget is chrome and four states. loading draws bars where the content will be and reports
aria-busy, error replaces the content with the message and — with an onRefresh — a retry, empty
says so in words rather than leaving a panel that looks broken, and anything else renders the children.
Outside a DashboardGrid a Widget is simply a card with the same chrome.
The layout is the artifact, so it is also the prompt. DashboardUtils.SCHEMA is the layout as JSON
Schema — inside the subset catalog() emits and <SpecRenderer> validates — and DashboardUtils.parse
reads one back from wherever it was kept, dropping what it cannot use and reporting it rather than throwing.
The schema says the shape and parse says the sense: a generated w: 0 or a column count of 400 is
clamped, and two items claiming one id become one. Where a dashboard is kept is the app's decision, since
only the app knows whether it belongs to a person, a team or a URL; the docs page keeps its demo in
localStorage, which is the whole of it.
The entry is 5.99 KB gzipped on top of Box. The two style-tree nodes it adds are in the engine with every other component's, so they cost 0.44 KB gzipped on every entry that carries one, dashboard or no dashboard.
A generated dashboard with something in it
catalog() described the components a model may compose and, for four of them, not the props that carry
what they are for. A <DataGrid> arrived with no def at all — though def is required — and a
<DashboardGrid> with no layout, so a generated spec could place a dashboard and neither lay it out nor
put a grid in it. Both are in the catalog now, and so are Widget's empty and ChartContainer's
series.
{
"type": "Widget",
"props": { "id": "orders" },
"slots": { "title": ["Orders"] },
"children": [
{
"type": "DataGrid",
"props": {
"data": { "$data": "orders" },
"def": {
"rowKey": "id",
"footer": true,
"columns": [
{ "key": "customer", "header": "Customer" },
{ "key": "total", "header": "Total", "align": "end", "aggregate": "sum" }
]
}
}
}
]
}These four are the props that are a shape rather than a value, and the reason they were missing is that
the other half of each shape is React: a column carries a Cell renderer and an onCellEdit, a widget's
empty is a ReactNode. The generator maps types, so it had to drop such a prop whole rather than state a
constraint that is not true. What a spec can write is now described by hand beside the interface it
mirrors — every key typed against it, so a prop renamed in the component is a compile error rather than a
constraint that silently stops matching.
The part a spec cannot write is still absent: dataSource, onCellEdit, rowDetail and treeData are
functions and components, passed beside the spec rather than in it, and a column's own nested columns
would be a recursive schema. data is the one prop in the catalog that carries values rather than styling,
so its schema says "objects" and stops — { "$data": "orders" } is the usual answer, and the rows stay
yours. Everything else is judged the way any other prop is: a column with no key, an aggregate that is
not one of the five, or a Cell written as a string fails the schema, so the prop is dropped and the node
renders without it.
DashboardUtils.SCHEMA is unchanged and is what DashboardGrid's layout now points at, so the artifact
a drag reports back, the one a host stores and the one a model generates under are one description in one
place. It moved into a module of its own to get there, which is the whole cost of this: 1.83 KB gzipped
on the catalog entry for the four contracts, 26 B on @box-kite/core for merging them, and 14 B on the
dashboard for the module boundary. Nothing else moved.
An agent's turn, in three components
@box-kite/react/components/agent is the chrome around what an agent does rather than what it says:
<ToolCallCard> for a call it made, <ApprovalCard> for one it wants permission to make, and
<Reasoning> for the thought behind both. Typed, themed, keyboard-complete, and 1.7 KB gzipped on top
of Box for all three.
import { ApprovalCard, Reasoning, ToolCallCard } from '@box-kite/react/components/agent';
<Reasoning duration={1400}>{reasoningText}</Reasoning>
<ToolCallCard name="searchOrders" status="success" input={{ orderId: 4182 }} output={{ total: 6400 }} />
<ApprovalCard
title="Refund order 4182"
description="6,400 MDL back to the customer. This cannot be undone."
input={{ orderId: 4182, amount: 6400 }}
onDecisionChange={(decision) => respond(decision === 'approved')}
/>;The status is a word, not a colour. pending, running, success and error each carry their own
label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had one.
The four are what every runtime already reports under its own spelling, so AI SDK's input-streaming /
input-available / output-available / output-error is a lookup rather than a state machine.
A value is whatever the model produced, so it is formatted rather than trusted. A tool's arguments
can be circular, hold a BigInt, or be four megabytes long — JSON.stringify answers those three with a
throw, a throw and a frozen frame. AgentUtils.formatValue is the judgement, framework-free and exported
from the same entry: the text is capped at valueLimit (20,000 characters) with a line saying how much
was left, and a value that cannot be serialised is described instead of taking the transcript with it.
onDecisionChange(decision, { reason }) is the approval card's whole API, which is what maps it onto
AI SDK 6's needsApproval, CopilotKit's renderAndWaitForResponse and AG-UI's INTERRUPT. Two things
it deliberately does not do: it does not take focus unless autoFocus says so — a turn arrives while the
reader is somewhere else, and a card that grabs the keyboard is one that gets answered by accident, which
is also why autoFocus lands on Reject — and it does not announce its own arrival, because the
transcript it is rendered into is what does that. What it owns is the answer, in a role="status" that
is in the DOM before there is anything in it, since a live region inserted together with its text is not
reliably read out.
Everything else follows the library's own rules. A ToolCallCard with nothing to disclose renders no
control at all, because a header that opens nothing is a tab stop nobody wants to land on. Reasoning is
closed by default and opens in the same one-row grid an Accordion panel does, so nothing is measured.
All three are in catalog(), so a generated UI can build a tool-loop transcript, and the trees are
toolCall, approval and reasoning for Box.components().
What the agent says, as it arrives
<StreamingText> is the other half of an agent's turn: the message itself. Hand it the text so far and it
fades in the part that was not there a render ago.
<StreamingText text={message} streaming={status === 'streaming'} />Only what arrived animates, and the cost does not grow with the message. What is on the page is one
settled string plus the last few runs to reach it — eight by default — so a message that is already whole
paints at once with nothing moving, which is what a prerendered page and a transcript read back both want,
and a message still arriving costs the same at the ten-thousandth token as at the first. window is that
number: 0 turns the entrance off, and a stream fast enough to fill the window inside one transition is
the case for raising it. The judgement is AgentUtils.advanceStream, framework-free like the rest of that
namespace.
The entrance is @starting-style rather than a keyframe, so it rides --transitionTime and disappears
under prefers-reduced-motion with nothing declared for it; the caret is the pulse preset, which stops
itself for the same reason. It is deliberately not a live region — one announcing every token reads the
message out a word at a time and again when it finishes — so what it carries is aria-busy, and what
announces an agent's turn is the transcript it lands in.
The style tree is streamingText, with segment and caret under it.
Markdown, and the dependency we did not take
A model writes markdown, and a parser is a choice most apps have already made — so what ships is the half
that is ours: markdownComponents, the components map that react-markdown, Streamdown and everything
built on that shape already takes, with this engine's classes on it.
import Markdown from 'react-markdown';
import { markdownComponents } from '@box-kite/react/components/markdown';
<Box component="markdown">
<Markdown components={markdownComponents}>{message}</Markdown>
</Box>;Wrapping a renderer was the other option and it is not worth it: the ones on offer ask a project for a
build-tool directive pointing into their dist/ and for design tokens declared in a global stylesheet —
which is the one thing this library exists not to need — and it would choose the parser for you. The map
costs no dependency, works with whichever renderer is already there, and keeps the promise: no stylesheet.
It is a constant, not a factory, and while streaming that is the whole difference. A map built inside
render is a new set of component types every token, which React answers by unmounting the message and
mounting it again; override a node by spreading at module scope instead. Whether a URL is safe stays the
renderer's, because by the time a component is called the href has been parsed — urlTransform or
defaultUrlTransform is where a javascript: link is refused. What the map sets is rel="noreferrer".
1.05 KB gzipped on top of Box, and the tree is markdown with heading, paragraph, link, list,
item, quote, code, codeBlock, rule, image, table, row, cell, inline and checkbox
under it.
Where the answer will be
<Skeleton> is the placeholder while something is being fetched: bars where the content goes, with a
gloss crossing them.
<Skeleton lines={3} label="Loading orders" />
<Skeleton circle width={10} />With no label the whole thing is aria-hidden, because a reader told "three empty bars" has been told
nothing; a label makes it a role="status" naming what is on its way, and it belongs on the one
skeleton standing for a region rather than on each bar. The gloss is a named duration, so it sits outside
what --transitionTime zeroes and stops itself under prefers-reduced-motion. It renders on a server —
no state, no effect, no measurement — and costs 0.35 KB gzipped on top of Box. The tree is skeleton,
with bar (whose short and circle variants are the last line and the avatar) and gloss.
The whole loop is in examples/next-app now: /agent is a real AI SDK tool loop where every part of a
turn is one of these components. AI SDK reports six tool states — four are a <ToolCallCard> status, and
the other two are an <ApprovalCard>, because a decision is not a stage a call passes through but a
question somebody has to answer.
Four agent runtimes, one vocabulary
@box-kite/react/interop is this library's shapes and the agentic ecosystem's, mapped onto each other.
It imports none of them: an adapter that pulled in a runtime would be choosing one for your app, and your
app has already chosen.
import { toolPart, a2uiApply, a2uiSurface, a2uiToSpec, a2uiCatalog } from '@box-kite/react/interop';
const mapped = toolPart(part); // AI SDK, assistant-ui or CopilotKit
<ToolCallCard name="searchOrders" status={mapped.status} input={mapped.input} output={mapped.output} />;Every runtime describes the same two things in words of its own — a tool call, and a tree of components.
toolPart(part) answers the first in the words <ToolCallCard> and <ApprovalCard> already take, and
the split worth knowing is that a decision is not a stage a call passes through: it is a question
somebody answers, which is why two components cover what AI SDK reports as six states, and why the three
runtimes reporting four keep their human-in-the-loop on a second channel. AG-UI is the odd one out — it
reports events rather than parts, so applyToolEvent(parts, event) is a fold rather than a mapping.
A2UI is the one that is genuinely a different shape, and so the one with real code behind it: a flat
adjacency list of components referring to each other by id, arriving one message at a time, with a data
model of its own per surface. a2uiApply folds a v0.8 or v0.9 stream, and a2uiToSpec walks a surface
into the tree <SpecRenderer> renders.
const [state, setState] = useState(a2uiEmpty);
const surface = a2uiSurface(state);
<SpecRenderer spec={a2uiToSpec(surface, { catalog })} registry={registry} data={surface?.data} onAction={run} />;Three things the two models turned out to already agree on. A2UI's data binding is a JSON Pointer and
$data has taken one since it was written, so a binding is a rename rather than a parse; a template
(children: { componentId, path }) is one node per item of an array, which is what repeat means; and a
half-arrived surface is the ordinary case, since an agent streams a leaf before the branch that holds it.
The other direction is a2uiCatalog(catalog()) — the same components and the same values their props
take, in the shape an adjacency list needs, which is what an agent generates against.
assistant-ui's GenerativeUISpec is the same idea arrived at twice, so fromGenerativeUi and
toGenerativeUi are a rename plus an honest accounting: their nodes carry no data binding, no repeat and
no action channel, so the second reports what it could not carry rather than emitting a tree that
renders half a view in silence.
Every runtime named is a devDependency of the repository and the adapters run against the published
packages rather than against a memory of them: @assistant-ui/core's own types accept what
toGenerativeUi emits, @copilotkit/a2ui-renderer's createCatalog accepts the document a2uiCatalog
builds, and a surface generated against that document renders here. One trap came out of it, measured
against zod 4.6: a $ref resolves against the document, not against the piece you lifted out of it,
so converting a catalog component on its own throws Reference not found: #/$defs/color —
a2uiComponentSchema(document, name) is that component with the document's definitions attached.
3.96 KB gzipped, and no engine in it. The recipes per runtime, AG-UI and json-render included, are in docs/interop.md and at box-kite.dev/interop.
npx @box-kite/mcp: the answer a documentation file cannot give
A file is read once, at the start of a session. An MCP server is asked mid-task, which is when the question actually comes up — and it can answer one thing no file can.
Every prop here accepts a closed set of values, and a value it does not accept writes no rule and no
class name. Silently, by design: a typo must never emit a broken declaration into a stylesheet
everything else shares. That is the right behaviour and it is invisible, so no amount of prose settles
whether bgColor="blue-550" works. check_styles hands your props to the real engine and reports what
each one wrote:
check_styles { "props": { "p": 4, "bgColor": "blue-550", "fontSize": 14, "href": "/about" } }
✅ p → .p-4{padding:1rem}
❌ bgColor does not accept "blue-550" — no rule and no class name were written.
✅ fontSize → .fontSize-14{font-size:0.875rem}
⚠️ href an HTML attribute, not a style prop. It goes in props={{ "href": … }}.For the same reason get_props measures a numeric prop's scale instead of describing its divider —
it runs 1, 2, 4 and 8 through the engine and prints what came out — and an unknown prop is answered with
the props that write the CSS property its name spells, so padding comes back as p and
backgroundColor as bgColor.
claude mcp add box-kite -- npx -y @box-kite/mcpAny client that speaks stdio takes the same command:
{
"mcpServers": {
"box-kite": { "command": "npx", "args": ["-y", "@box-kite/mcp"] }
}
}Six tools, at capability level rather than one per document. search_docs ranks the props, the
components, the nesting keys and the rules together, so "fade in when it mounts" answers startingStyle
and "style every other row" answers nth without either name being known; get_component carries a
component's props, its sub-parts, its keyboard map and the ARIA it writes; get_rules and get_blocks
are the rules themselves and the sections the shadcn CLI installs.
No key, no network and no state: the prop reference, the component reference, the rules and the styling
engine are all built into the package at the version you install, so check_styles and get_props
cannot disagree with each other or with the library you are writing against. context7.json ships in
the repository too, for the aggregator half of the same job.
A block of cells, and Ctrl+C
Every DataGrid now marks the cell its arrows carry on from — whether the pointer or the keyboard put it
there, and it stays marked once the grid loses focus. Ctrl+C on it copies that cell. def.rangeSelection
is the rest: drag across cells, or hold Shift with the arrow keys, and the block that is marked is what
Ctrl+C writes, tab-separated — the text Excel, Sheets and Numbers all paste as columns.
<DataGrid
data={people}
def={{
rowKey: 'id',
rangeSelection: true,
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'country', header: 'Country' },
{ key: 'age', header: 'Age', align: 'end' },
],
}}
onRangeChange={(range) => setSummary(range?.values())}
/>The mark is state on the grid rather than a :focus-visible ring, which is what fixes it: that
pseudo-class never matches a pointer, so a clicked cell drew nothing, and a ring made of focus goes out
the moment the grid loses it — leaving a copy with nothing to act on. The cells of a block report
aria-selected and the grid is aria-multiselectable; a lone current cell reports neither, because it has
chosen nothing. onRangeChange hands over the rectangle and values(), which reads what is in it through
the same pipeline an export uses — a column's exportValue where it has one, and an accepted edit over the
row — so the figure a status bar sums is the figure a .csv would carry.
A drag can mark cells or select text and never both, so a grid with rangeSelection on gives text selection
up; an open editor hands it back for the value being typed. A touch is left alone entirely, since that press
is how the grid scrolls.
Ctrl+V, one judgement per cell
A block of cells goes back the other way now. Ctrl+V fills from the current cell — or from the block that
is marked, where one is — and every cell it covers goes through the same def.onCellEdit an editor would,
so a paste needs no second validator, no second event and no second way of writing a value. onPaste
reports the whole block once: what was written, what was refused and with which message, and how many cells
nothing could be written to.
<DataGrid
data={people}
def={{
rowKey: 'id',
rangeSelection: true,
columns: [
{ key: 'first_name', header: 'First name', editable: true },
{ key: 'salary', header: 'Salary', align: 'end', editable: true },
],
onCellEdit: ({ columnKey, value }) => (columnKey === 'salary' && Number(value) < 0 ? 'Salary cannot be negative' : undefined),
}}
onPaste={({ applied, rejected }) => setReport({ written: applied.length, refused: rejected.length })}
/>Each axis takes whichever is longer, the block or the clipboard: a block bigger than the clipboard is tiled with it, a clipboard bigger than the block spills past it, and both stop at the edge of the grid. One rule, with the degenerate case falling out of it — a paste onto the current cell alone starts from a block of one.
A refusal skips its own cell and nothing else, because a paste is many independent judgements and stopping
at the first bad value would leave the block half written with no way back. The refused cells wear the red
ring and report aria-invalid: there is no editor open on any of them to show a message in, so the cells
say which ones and onPaste says why. Since the clipboard carries no types, the cell is what says how to
read the text — a number column refuses what is not a number, a checkbox takes true/false, and a
select keeps to its own options and holds the option's value rather than its spelling.
A paste that reaches an open editor belongs to the editor: that is a value being typed, not a block being
filled. Everything a paste accepts lands in the same stream a typed value does, with a reason of paste.
Ctrl+V, one judgement per cell
A fling renders where you are going
A DataGrid kept twenty rows rendered on each side of its viewport, whichever way the rows were actually
moving — fifty-eight of them around an eighteen-row screen, roughly twice what a virtualized body needs. A
buffer is cover for the frame between a scroll and the render that
answers it, so it is only ever wanted in the direction of travel. It is twelve rows ahead and four
behind now, and it turns round when the reader does. Nothing to configure, and no prop changed.
Thirty-six rows instead of fifty-eight, on the same quiet laptop the benchmark's published figures come from: the median frame of a fling over a hundred thousand rows went from 12.3 ms to 8.0 ms — 81 frames a second to 125 — with the work inside the frame down from 19.8 ms to 15.3, and first render 89 ms to 81, filter 65 to 42 and sort 89 to 76 carried along with it.
The number that keeps that honest is new, because a grid that renders nothing at all is the fastest grid on the page. The benchmark now flicks each grid past at ten thousand pixels a second — the top of what a hard flick reaches, five rows between one frame and the next at 60 fps — and hit-tests four points down it at the start of every frame, against the scroll position that frame is about to paint. This grid paints every one of those frames. With the buffer taken away altogether the same pass reports every frame blank, which is what says it is looking at something; with eight rows of cover instead of twelve, four frames in a hundred.
A hundred thousand rows, and a page that measures them
There is a benchmark page on the docs site now — box-kite.dev/benchmark — and it is not a table of numbers somebody typed in. It generates a hundred thousand rows of twenty columns in your own browser, drives the grid through five operations and reports what it measured on your machine: first render, a two-second fling, a column filter, a sort and a grouping with totals. What it leaves out is on the page beside the numbers, because a benchmark that only flatters the thing it measures is an advertisement.
Writing it found two renders the DataGrid was doing for nothing, and both are fixed here. A scroll that
did not change which rows were on screen was re-rendering every cell in the window; it now costs the
transform and nothing else. A scroll that brought one new row in was re-rendering every row in the window;
it now renders the row that arrived. On the machine the published figures come from, the median frame of a
fast fling over a hundred thousand rows halved, from 24 ms to 12 ms — forty-one frames a second to
eighty-two, with the worst frame down from 38 ms to 24 — and not a prop changed.
What it says about this grid, on one quiet laptop, five runs of a hundred thousand rows by twenty columns: first render, filter, sort and grouping all land inside a frame — and the fling was the one it was slowest at, about 20 ms of work a frame, eighty-one frames a second. That is what the next section is about: the fling is inside a frame now, and what is left is the work inside it.
npm run bench is the same measurement headless, and it is the whole harness: the page is the benchmark
and the script only presses its button, so a rerun in CI measures the code a reader measures. It runs on
every pull request that touches the grid or the engine, against budgets sized for a shared runner — and
only for this grid.
npx shadcn add @box-kite/data-grid
There is a shadcn registry on the docs site now, and three blocks in it — an invoices data grid, a settings form and a dashboard shell. A block is not a component: it is a finished section that the CLI writes into your own repository, so the composition is yours to edit from the first commit while the components under it stay a package you upgrade.
npx shadcn@latest add https://box-kite.dev/r/data-grid.jsonRegister the namespace once in components.json and the address becomes a name — npx shadcn@latest add @box-kite/settings-form, and npx shadcn@latest search @box-kite lists what is there:
{
"registries": {
"@box-kite": "https://box-kite.dev/r/{name}.json"
}
}No stylesheet anywhere. The CLI expects a components.json carrying a tailwind key, and empty
strings satisfy it, because these blocks import no stylesheet and write no CSS file — every style in them
is a prop. Verified end to end against a fresh create-next-app: the files land, lucide-react installs
with the dashboard shell, and next build prerenders the page with the CSS in the HTML.
The blocks are ordinary sources in the library's own repository, type-checked by the same tsc run as the
library and rendered live on the registry page from those same files, which
are inlined into the JSON at build time. So a block that stops compiling cannot be published, and what the
CLI writes is what the page is running.
The DataGrid exports its types
Every other component exports its own types; components/dataGrid exported only the component. Typing a
column list, a ref or a cell renderer outside the JSX therefore meant importing from
@box-kite/react/components/dataGrid/contracts/dataGridContract, a path that is plainly private. The whole
contract comes off the component's own module now:
import DataGrid, { type CellModel, type ColumnType, type DataGridHandle, type GridDefinition } from '@box-kite/react/components/dataGrid';
function StatusCell({ cell }: { cell: CellModel<Invoice> }) {
return <Badge>{cell.value as string}</Badge>;
}
const definition: GridDefinition<Invoice> = { rowKey: 'id', columns: [{ key: 'status', Cell: StatusCell }] };Types only — nothing is added to the bundle, and the deep import still resolves, so nothing that works today stops working.
A theme inside a theme
A theme is an ancestor class, so two of them on one page wrote two rules of exactly the same specificity —
and the one written last won, however far away it was. That made <Box.Theme use="local"> a coin toss: a
light panel inside a dark page stayed dark, and every built-in component's dark styling stayed with it,
since those are written as a dark override over a light base rather than as a pair of themes.
Every theme rule is scoped to the subtree its theme owns now, and ends at the next element declaring one:
@scope (.light) to ([data-theme]) {
:scope .theme-light-bgColor-white {
background-color: var(--white);
}
}So the nearest theme wins — for a property the inner theme never mentions as much as for one it does, and
even where the outer theme's rule carries one more pseudo-class, which proximity alone would have lost to.
What marks a theme root is data-theme, and <Box.Theme> renders it beside the class instead of writing
it from an effect, so the boundary is in the HTML the first paint uses. A theme class you set by hand — the
one a prerendered shell puts on <html> — wants the attribute beside it.
<Box.Theme use="local" theme="light">
<Box p={4} theme={{ light: { bgColor: 'white' }, dark: { bgColor: 'slate-950' } }}>
Light in here, whatever the page around it is.
</Box>
</Box.Theme>Theming now needs a 2023 browser
Nothing in the API changed, so nothing you have written has to change — but the rule this release generates
for a theme block is an @scope block, and that is Chrome 118+, Safari 17.4+ or Firefox 128+. Below
those versions the theme rules are dropped rather than misapplied: an element shows its unthemed values,
which for every pre-built component is the light design, because each of their theme blocks is a dark
override over a light base. There is no error and nothing in the console — it simply looks unthemed.
If you support a browser below that floor, write the design it should get as the plain props and the other
one under theme, which is what the pre-built components already do:
// The old browser gets the light design; the new one gets either.
<Box bgColor="white" color="slate-900" theme={{ dark: { bgColor: 'slate-900', color: 'white' } }} />A site about this library, and an answer for the agent writing it
box-kite.dev no longer argues with other libraries. The three comparison pages — the utility-family parity table, the accessible-pattern comparison and the data grid feature matrix — have been removed, along with the picker that ran the benchmark against three other grids and every versus line scattered through the component pages. A documentation site that argues with other libraries is a site about those libraries, and a reader who arrived to find out what this one does had to read past the argument to get there.
The numbers were worth keeping, so they were kept. They are a development instrument now rather than a
page: npm run compare prints all three tables in a terminal, and takes a section name to print just
one. The data and its tests moved to dev/compare/, and the tests still run in
npm test — they hold each table to the live prop registry and to this site's own routes, so a prop added
and not mapped still fails the suite. npm run compare runs in CI too, which proves the report prints;
the tests are what prove it is right. The benchmark page keeps its Run button and measures this grid
alone, with the other three adapters in dev/compare/grids/ and wired in outside a production build — so
npm run dev still offers the four-way picker.
What the site leads with instead is the thing that is actually unusual about it. Everything an agent
needs to write this library correctly ships inside the package, generated from the prop registry at build
time: AGENTS.md, docs/props.md with every prop's measured example, a docs folder beside it, the skill,
the Cursor rule — and npx @box-kite/mcp, whose check_styles runs the real engine, because a value this
library refuses writes no CSS and says nothing about having done so. The homepage is four claims in that
order now (the instructions ship with the code, the compiler catches the rest, a model can compose the UI
at runtime, and what it writes is finished), /ai-context has become Built for AI and sits at the top
of the nav, and Generative UI, Interop and Agent are a section of their own.
The /migrating page is gone with them — it existed for a rename nobody had to follow, and the three
things the compatibility bridge cannot forward are still written down in the 1.0.0 notes and in the
bridge's own README, which is where somebody still on the old package would be reading.
Nothing in the library changed. This is the documentation site, the README and one bench/results.json
trimmed to the runs it still prints.
/box, rebuilt for somebody who has never seen this library
/box is the page every component's API reference points at — "all 221 of Box's style props work on it
too, and those are on /box" — and it was the page that taught the least. It opened on a nine-tab switcher
over forty-six hand-written demo cards, with no sentence anywhere saying what a Box is, what a number
on a prop means, or why there is no style attribute. The four facts that are silently wrong against a
memory of another library were written down for a machine, in AGENTS.md, and nowhere on the site for a
person.
It reads top to bottom now, with nothing to click before the first sentence: what a Box is, then the
five things to know first — each one printing the guess small and the truth in the heading, with the
wrong line and the right line side by side — then the numbers, then a card built one prop at a time over
six steps, then the nesting keys, then the nine props that are not styles at all (tag, props,
component, style and why it exists).
The numbers section is measured rather than described. Put a number in the box and every scale family
shows the CSS it really writes for it, resolved by an isolated createStyleEngine() running in the page —
so p={16} is padding: 4rem and fontSize={16} is font-size: 1rem, side by side, and the divider
cannot drift away from the engine that owns it.
The nine tabs are one search box. Every one of the 221 props is in a single table with the CSS it
emits, filterable by twelve families and searchable by prop name, by CSS property or by a value you are
looking for — read from api/props.json, which npm run docs:props measures from the engine, so no
example on the page is written by hand. The forty-six demo cards covered a fifth of the registry and were
the last prop documentation on the site that a person maintained.
/box-functions is the other half, and it is new: Box.extend(), Box.components(),
Box.keyframes(), Box.spring(), Box.Theme, Box.useTheme(), Box.configure(),
Box.getVariableValue(), useClassNames() and useVisibility(), each with its signature, the smallest
real example and a link onward. Three of them were documented nowhere at all, including useClassNames(),
which is the only way to put Box props on an element this library does not render.
Nothing in the library changed.
Breaking changes
None.
Fixes
- A component documented none of the props it inherited from another component.
Gaugeis aProgressRingwith a shorter sweep, so itsvalue— the whole point of a gauge — was missing from its API page, from the catalog and from the manifest, and a generated<Gauge>could not be given one; the four chart primitives were missing thelabelthat keeps them out ofaria-hidden, andMenu.CheckboxItem/Menu.RadioItemwere missingdisabled. The extraction follows a props interface into whatever this repository declared behind it now, and stops where Box's own props begin. (#185) - A grid was unmounted and rebuilt half a dozen times in the closing moments of a stream. A stream is not monotone: a column's
alignpasses through"e"on its way to"end", which fails its own schema and takes the wholedefwith it, so the prop the grid cannot render without went missing every few frames.<SpecRenderer>keeps the last value each node was given for such a prop — what a node has been shown with, it is not stripped of — and a heavy component can be gated by the app on top of that: point its name at a placeholder in the registry while the spec is arriving. (#188) - A generated component was handed
undefinedfor a prop it cannot do without. ASparklinewhosedatahad not arrived yet — or was refused — threw, and only the node's own error boundary caught it. The renderer holds such a node back and reportsmissing-propinstead, so a streamed spec paints the same thing with nothing thrown behind it. (#186) - The catalog said a layout component could hold nothing.
Flex,Grid,Button,Icon,Overlayand eleven others reported nodefaultslot, because they declare nochildrenprop of their own — they take Box’s props whole — whileImgreported one it cannot have. A generated tree read off that catalog could not nest anything in aFlex. Every component that can hold children says so now, and one whose element takes none (Img,Textbox,Textarea) says that instead. (#180) - A DataGrid rendered fifty-eight rows around an eighteen-row viewport. Twenty rows each side became twelve ahead of the scroll and four behind it, so the same cover costs thirty-six rows: a fling over a hundred thousand rows went from 12.3 ms a frame to 8.0, and first render, filter and sort came down with it. (#178)
- A DataGrid re-rendered every cell on screen on every scroll event. A scroll that does not change which rows are shown now costs nothing but the transform, and one that brings a row in renders that row rather than the window it landed in — the median frame of a fast fling over a hundred thousand rows halved, 24 ms to 12 ms.
- A DataGrid cell chosen with the pointer showed nothing, and no cell stayed marked once the grid lost focus. The mark is the grid's own state now rather than a
:focus-visiblering, so a clicked cell wears it, it survives a blur and a scroll, andCtrl+Chas something to copy. (#64)