@box-kite/react 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.claude/rules/box-kite-rules.md +16 -0
  2. package/.claude/skills/box-kite/SKILL.md +16 -0
  3. package/.claude/skills/box-kite/references/components.md +8 -3
  4. package/.claude/skills/box-kite/references/patterns.md +23 -0
  5. package/.cursor/rules/box-kite.mdc +16 -0
  6. package/AGENTS.md +19 -0
  7. package/BOX_AI_CONTEXT.md +562 -1
  8. package/BOX_KITE_AI_CONTEXT.md +562 -1
  9. package/README.md +39 -7
  10. package/catalog.cjs +1 -0
  11. package/catalog.d.ts +8 -0
  12. package/catalog.mjs +2333 -0
  13. package/client.cjs +1 -1
  14. package/client.mjs +40 -42
  15. package/components/agent.cjs +1 -0
  16. package/components/agent.d.ts +215 -0
  17. package/components/agent.mjs +405 -0
  18. package/components/chart.d.ts +1 -0
  19. package/components/dashboard.cjs +1 -0
  20. package/components/dashboard.d.ts +121 -0
  21. package/components/dashboard.mjs +690 -0
  22. package/components/dataGrid/components/dataGridBody.d.ts +2 -1
  23. package/components/dataGrid/components/dataGridDetailRow.d.ts +13 -2
  24. package/components/dataGrid/components/dataGridGroupRow.d.ts +13 -2
  25. package/components/dataGrid/components/dataGridRow.d.ts +13 -2
  26. package/components/dataGrid/contracts/dataGridContract.d.ts +66 -2
  27. package/components/dataGrid/models/cellModel.d.ts +3 -0
  28. package/components/dataGrid/models/editModel.d.ts +27 -0
  29. package/components/dataGrid/models/exportModel.d.ts +2 -1
  30. package/components/dataGrid/models/gridModel.d.ts +3 -0
  31. package/components/dataGrid/models/rangeModel.d.ts +119 -0
  32. package/components/dataGrid/models/viewportModel.d.ts +23 -2
  33. package/components/dataGrid/useGridNavigation.d.ts +10 -2
  34. package/components/dataGrid.cjs +4 -1
  35. package/components/dataGrid.d.ts +3 -0
  36. package/components/dataGrid.mjs +1056 -765
  37. package/components/markdown.cjs +1 -0
  38. package/components/markdown.d.ts +55 -0
  39. package/components/markdown.mjs +179 -0
  40. package/components/semantics.d.ts +29 -29
  41. package/components/skeleton.cjs +1 -0
  42. package/components/skeleton.d.ts +34 -0
  43. package/components/skeleton.mjs +25 -0
  44. package/dashboardSchema.cjs +1 -0
  45. package/dashboardSchema.mjs +78 -0
  46. package/docs/a11y.md +2 -3
  47. package/docs/catalog.md +451 -0
  48. package/docs/components.md +4 -0
  49. package/docs/index.md +3 -0
  50. package/docs/interop.md +237 -0
  51. package/docs/mcp.md +69 -0
  52. package/docs/props.md +3 -3
  53. package/interop.cjs +1 -0
  54. package/interop.d.ts +51 -0
  55. package/interop.mjs +454 -0
  56. package/mcp/src/data.d.ts +88 -0
  57. package/mcp/src/data.test.d.ts +1 -0
  58. package/mcp/src/engine.d.ts +23 -0
  59. package/mcp/src/index.d.ts +1 -0
  60. package/mcp/src/search.d.ts +26 -0
  61. package/mcp/src/search.test.d.ts +1 -0
  62. package/mcp/src/server.d.ts +18 -0
  63. package/mcp/src/server.test.d.ts +1 -0
  64. package/mcp/src/styles.d.ts +27 -0
  65. package/mcp/src/styles.test.d.ts +1 -0
  66. package/mcp/src/tools.d.ts +18 -0
  67. package/mcp/src/vocabulary.d.ts +41 -0
  68. package/package.json +124 -17
  69. package/react/spec/renderSpec.d.ts +51 -0
  70. package/react/spec/specBoundary.d.ts +25 -0
  71. package/react/spec/specRegistry.d.ts +42 -0
  72. package/react/spec/specRenderer.d.ts +10 -0
  73. package/registry/blocks/dashboard-shell/dashboard-shell.d.ts +22 -0
  74. package/registry/blocks/dashboard-shell/dashboard-stats.d.ts +13 -0
  75. package/registry/blocks/data-grid/invoice-rows.d.ts +15 -0
  76. package/registry/blocks/data-grid/invoices-grid.d.ts +1 -0
  77. package/registry/blocks/settings-form/settings-form.d.ts +17 -0
  78. package/spec.cjs +2 -0
  79. package/spec.d.ts +12 -0
  80. package/spec.mjs +382 -0
  81. package/specData.cjs +1 -0
  82. package/specData.mjs +69 -0
  83. package/specDefs.cjs +1 -0
  84. package/specDefs.mjs +33 -0
  85. package/specSchema.cjs +1 -0
  86. package/specSchema.mjs +88 -0
  87. package/utils/agent/agentUtils.d.ts +81 -0
  88. package/utils/catalog/catalogContracts.d.ts +19 -0
  89. package/utils/catalog/catalogManifest.d.ts +8 -0
  90. package/utils/dashboard/dashboardSchema.d.ts +18 -0
  91. package/utils/dashboard/dashboardUtils.d.ts +97 -0
  92. package/utils/interop/a2uiInterop.d.ts +82 -0
  93. package/utils/interop/assistantUiInterop.d.ts +34 -0
  94. package/utils/interop/toolInterop.d.ts +30 -0
  95. package/utils/spec/specData.d.ts +19 -0
  96. package/utils/spec/specDefs.d.ts +11 -0
  97. package/utils/spec/specProps.d.ts +15 -0
  98. package/utils/spec/specSchema.d.ts +23 -0
  99. package/utils/spec/specTypes.d.ts +71 -0
  100. package/utils/spec/specValidate.d.ts +10 -0
@@ -62,4 +62,20 @@ globs: '**/*.{ts,tsx,jsx}'
62
62
 
63
63
  37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox`, everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the four (`text`/`number`/`checkbox`/`select`) or configures it (`{ type, options?, placeholder?, step?, min?, max? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
64
64
 
65
+ 38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
66
+
67
+ 39. **The block goes back in with Ctrl+V, one judgement per cell.** A paste 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 there is no second validator, no second event and no second way of writing a value; accepted cells land in the same stream a typed one does, with a `reason` of `'paste'`. It needs no opt-in, for the reason Ctrl+C does not: `def.rangeSelection` only gives it a bigger target. **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. **Four things to know.** **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 (`datagrid.body.cell`'s `isInvalid`, with no editor open behind it) and report `aria-invalid`, so the cells say which ones and **`onPaste({ range, applied, rejected, skipped })`** says why — once for the whole block, after the values have landed, so `range.values()` reads what the grid now shows. **The clipboard carries no types, so the cell says how to read the text**: a number column parses it and refuses what is not a number, a checkbox takes `true`/`false` (and `1`/`0`), and a `select` keeps to its own options and holds the option's value rather than its spelling. **A value that did not change is not an edit at all**, so pasting a column back over itself — the way a spreadsheet is used — costs nothing and reports nothing. And **a paste that reaches an open editor belongs to the editor**: that is a value being typed, not a block being filled, so the grid hands it back rather than filling the cells under it; a cell nothing can be written to is skipped before anything is asked about it, and counted.
68
+
69
+ 40. **A UI a model writes at runtime is `catalog()` and `<SpecRenderer>`, and both ends of it are the app's.** `catalog({ include, exclude, styleProps })` (`@box-kite/react/catalog`) is every component and every value its props take, as JSON Schema — read off the live prop registry, so a `Box.extend()` prop or colour is in it with nothing regenerated — and `specSchema(registry)` turns that into the one schema a model generates a whole _tree_ under, for `streamObject`, a structured-output API or `z.fromJSONSchema` — exported from **both** entries, and in a route handler it is `@box-kite/react/catalog`'s you want, since `/spec` renders and carries a `use client` banner; it takes a `catalog()` as readily as a registry, so the server half needs no components. `<SpecRenderer spec registry data onAction />` renders what came back, and `createSpecRegistry({ catalog, components: { Flex, H2, Sparkline } })` is the allow-list: a name it does not hold renders nothing at all. **A node is `{ type, props, children, slots, on, repeat }`, and every field of it is checked** — a prop the component's own schema refuses is dropped (the prop, not the node), 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 yours), and there is no tag from the spec, no `eval` and no `dangerouslySetInnerHTML` anywhere in it. **Four things to know.** A spec **arrives in pieces**, so a node whose `type` has not been written yet renders nothing _and reports nothing_, while a value that is still half a string fails its schema and is dropped until it is whole — and a node still missing a prop its component cannot do without is held back with a `missing-prop`, since a required prop that is absent would be `undefined` inside the component. **A generated node writes the controlled prop and never the `default…` twin** (`layout`, not `defaultLayout`; `value`, not `defaultValue`): an uncontrolled default is read once, so the frame it first arrived whole in is the one that sticks, and a second spec rendered in the same place keeps the first one's state. A stream is not monotone either — a column's `align` passes through `"e"` on its way to `"end"`, taking the whole `def` with it — so `<SpecRenderer>` keeps the last value each node was given for a prop it cannot render without, and a heavy component is the app's to gate: point its name at a placeholder in the registry while the spec is arriving. Every node has an **error boundary of its own**, so a component that throws on props a model invented costs that node and nothing around it. `{ $data: 'stats.revenue' }`, `{ $item: 'label' }` and `{ $index: true }` read from the `data` prop **before** the schema judges them, so what the host supplied is what is validated — a path, never an expression. And everything refused is **reported** rather than swallowed: `onIssues` gets a code (`unknown-component`, `invalid-prop`, `unknown-event`…) and the path it happened at. **The props that are a shape are described by hand**, because the other half of each of them is React: `DataGrid`'s `def` (required — the columns, plus `rowKey`/`title`/`footer`/`groupBy` and the rest of the flags) and `data` (the rows, the one prop in the catalog carrying values rather than styling — `{ $data: 'orders' }` is the usual answer), `DashboardGrid`'s `layout`/`defaultLayout` (`DashboardUtils.SCHEMA`, the artifact a drag reports back) and `columns`, `Widget`'s `empty`, `ChartContainer`'s `series`. What a spec cannot write stays out: a column's own nested `columns` is a recursive schema, and `dataSource`/`onCellEdit`/`rowDetail`/`treeData` are the app's to pass beside the spec. `maxNodes` (1,000) and `maxDepth` (32) end a runaway tree, `fallback` is what stands where a node could not render (nothing, unless you say otherwise), and `renderSpec()` is the same walk with no hook in it, for a server or a test.
70
+
71
+ 41. **A dashboard people rearrange is `<DashboardGrid>` plus `<Widget>`, and its layout is JSON in cells.** `<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable><Widget id="revenue" title="Revenue"><Sparkline data={revenue} width="100%" height="100%" /></Widget></DashboardGrid>` (`components/dashboard`): the layout is `{ version, columns, items: [{ id, x, y, w, h, minW?, minH?, maxW?, maxH?, fixed? }] }` and a `Widget` fills the item carrying its `id` — one with no item is left to the grid's own flow, and outside a grid a `Widget` is a card. **Nothing is measured to lay it out**: a place is `gridColumnStart`/`gridColumnEnd`/`gridRowStart`/`gridRowEnd`, which are props, so it is a shared class and the grid renders on a server; the one inline style is the translate that keeps a dragged widget under the pointer (the `Slider` exception, for the same reason). The layout is **compacted upward**, so a widget cannot be parked in mid-air and two dashboards of the same widgets in the same places compare equal — and a widget dropped on a neighbour **takes the cell**, the neighbour being handed the row above where there is room for it and the row below otherwise, since compaction on its own reads the layout in reading order and would simply undo the drop. **`columns` is a count per container size** (`{ xs: 1, md: 6, xxl: 12 }` by default, the `cq` scale), and every narrower arrangement is that one layout _projected_ — arithmetic at render, written as a container query, so the browser picks between classes. Two traps behind that, both measured: **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**, so where a projection is showing the handles are not rendered at all. **Four things to know.** `editable` is the whole edit/view split — without it there are no handles and nothing in the tab order — and both handles are real buttons: **dragging is not a keyboard gesture, so the keyboard gets a grab**, Enter or Space to pick the widget up, the arrows to move or resize it a cell at a time (the _reading order_, so ArrowLeft moves it right in a right-to-left page), Enter to drop and Escape to put it back, every step announced in a live region that was there first. `onLayoutChange` fires on every cell a drag crosses and `onLayoutCommit` once at the end — the one to write to a server — both with a reason of `'move'` or `'resize'`, the device being in `details.event`. A `Widget` is chrome and **four states**: `loading` (bars, `aria-busy`), `error` (the message, plus a retry where there is an `onRefresh`), `empty`, and otherwise its children; its `title` is a real heading at `level` (default 3), and `name` is what the handles and the announcements call it. And **the layout is the artifact, so it is also the prompt**: `DashboardUtils.SCHEMA` is it as JSON Schema — the subset `catalog()` emits — and `DashboardUtils.parse(value)` reads one back from a database or a model, clamping what is out of range and reporting what it dropped rather than throwing. Where it is kept is the app's. Styling is the `dashboard`/`dashboard.placeholder` and `widget`/`widget.header`/`widget.label`/`widget.title`/`widget.description`/`widget.actions`/`widget.handle`/`widget.body`/`widget.message`/`widget.retry`/`widget.skeleton` tree; both handles are the one `handle` node, whose `corner` variant is the resize one, and the row height is a variable (`vars={{ 'dashboard-row': '8rem' }}`) rather than a prop.
72
+
73
+ 42. **A theme is an ancestor, and the nearest one owns the subtree.** `theme={{ dark: { bgColor: 'slate-900' } }}` nests on any element and takes everything else with it (`theme={{ dark: { hover: … } }}`, `md={{ theme: … }}`); `<Box.Theme use="global">` puts the theme class and `data-theme` on `<html>`, `use="local"` on a wrapper of its own, and `Box.useTheme()` reads and sets it. The name is any identifier, not just `light`/`dark`. **A theme inside a theme is a real boundary**: each rule is `@scope (.dark) to ([data-theme]) { :scope .className … }`, so a local `<Box.Theme theme="light">` inside a dark page is light throughout — including for a prop the inner theme never mentions, and including where the outer theme's rule carries one more pseudo-class. What ends a theme is **`data-theme`**, so a theme class written by hand (the one a prerendered shell puts on `<html>`) wants the attribute beside it; `<Box.Theme>` renders both. A theme costs no re-render — it is one class swapping on an ancestor — and it needs Chrome 118+, Safari 17.4+, Firefox 128+, below which theme rules are dropped and the unthemed values show.
74
+
75
+ 43. **An agent's turn is `<ToolCallCard>`, `<ApprovalCard>` and `<Reasoning>`.** `<ToolCallCard name="searchOrders" status="running" input={{ query: 'refunds' }} />`, `<ApprovalCard title="Refund order 4182" onDecisionChange={(decision) => respond(decision === 'approved')} />` and `<Reasoning duration={4200}>{thought}</Reasoning>` (`components/agent`) are the three parts of a turn that are not prose: what it ran, what it wants permission to do, and what it was thinking. **The status is a word, not a colour** — `pending`/`running`/`success`/`error` each carry their own label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had it — and the four map one for one onto what every runtime 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**: `AgentUtils.formatValue` (framework-free, same entry) caps the text at `valueLimit` — 20,000 characters, with a line saying how much was left — and answers a circular structure, a `BigInt` and a function, all three of which make `JSON.stringify` throw or drop them silently. **Four things to know.** `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`; the answer lands in a `role="status"` **that is in the DOM before there is anything in it**, the toaster's rule, and `busy` is the round trip to a server. The card **does not take focus** unless `autoFocus` says so, and then on _Reject_ — APG's least-destructive rule — because a turn arrives while the reader is somewhere else. A `ToolCallCard` with nothing to disclose **renders no control at all**, the header being a row of text rather than a button nobody wants to land on. And `Reasoning` is **closed by default** and opens in the same `1fr`-to-`0fr` grid an `Accordion` panel does, so nothing is measured: `streaming` only changes the words and the shimmer, and `open={streaming}` is how an app makes it follow. Styling is the `toolCall`/`approval`/`reasoning` trees, where the status is a variant on `toolCall.status`, the decision one on `approval` itself, and `toolCall.header`'s `interactive` variant is what makes a header look pressable.
76
+
77
+ 44. **What it _says_ is `<StreamingText>`, `markdownComponents` and `<Skeleton>`.** `<StreamingText text={message} streaming />` (`components/agent`) takes the text so far and fades in the part that was not there a render ago — **hand it the whole message every time, not the delta**, because knowing what is new means comparing the two. What is on the page is one settled string plus the last few runs to arrive (`window`, 8 by default; `0` turns the entrance off), so a message already whole paints at once with nothing moving — a prerendered page, a transcript read back — and one still arriving costs the same at the ten-thousandth token as at the first. The entrance is `@starting-style` rather than a keyframe, so it rides `--transitionTime` and stops under `prefers-reduced-motion` on its own, and the element is deliberately **not** a live region: one announcing every token would read the message out a word at a time and again when it finished, so it carries `aria-busy` and the transcript does the announcing. **The markdown bridge is a map, not a component**: `markdownComponents` (`components/markdown`) is the `components` map `react-markdown`, Streamdown and everything on that shape already take, so the parser stays the app's and there is no stylesheet, no Tailwind `@source` line and no design tokens to declare — wrap the renderer in `<Box component="markdown">` and the nodes inherit its size and colour. It is a **constant**, and that matters while streaming: a map built inside render is a new set of component _types_ every token, which React answers by unmounting the whole message, so override by spreading at module scope (`{ ...markdownComponents, h1: MyHeading }`). Whether a URL is safe stays the renderer's `urlTransform`, since the href is parsed before a component is called. **`<Skeleton lines={3} />`** (`components/skeleton`) is the placeholder — `circle` for an avatar, the gloss a named duration so it stops itself — and it is `aria-hidden` unless given a `label`, which makes it one `role="status"`: put that on the skeleton standing for a region, never on every bar. Styling is `streamingText` (`segment`, `caret`), `skeleton` (`bar` with `short`/`circle`, `gloss`) and `markdown` (`heading`, `paragraph`, `link`, `list`, `item`, `quote`, `code`, `codeBlock`, `rule`, `image`, `table`, `row`, `cell`, `inline`, `checkbox`).
78
+
79
+ 45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.
80
+
65
81
  Full reference: `src/BOX_KITE_AI_CONTEXT.md` or invoke `/box-kite` skill.
@@ -135,4 +135,20 @@ Detect the package manager from the lock file rather than asking:
135
135
 
136
136
  37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox`, everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the four (`text`/`number`/`checkbox`/`select`) or configures it (`{ type, options?, placeholder?, step?, min?, max? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
137
137
 
138
+ 38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
139
+
140
+ 39. **The block goes back in with Ctrl+V, one judgement per cell.** A paste 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 there is no second validator, no second event and no second way of writing a value; accepted cells land in the same stream a typed one does, with a `reason` of `'paste'`. It needs no opt-in, for the reason Ctrl+C does not: `def.rangeSelection` only gives it a bigger target. **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. **Four things to know.** **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 (`datagrid.body.cell`'s `isInvalid`, with no editor open behind it) and report `aria-invalid`, so the cells say which ones and **`onPaste({ range, applied, rejected, skipped })`** says why — once for the whole block, after the values have landed, so `range.values()` reads what the grid now shows. **The clipboard carries no types, so the cell says how to read the text**: a number column parses it and refuses what is not a number, a checkbox takes `true`/`false` (and `1`/`0`), and a `select` keeps to its own options and holds the option's value rather than its spelling. **A value that did not change is not an edit at all**, so pasting a column back over itself — the way a spreadsheet is used — costs nothing and reports nothing. And **a paste that reaches an open editor belongs to the editor**: that is a value being typed, not a block being filled, so the grid hands it back rather than filling the cells under it; a cell nothing can be written to is skipped before anything is asked about it, and counted.
141
+
142
+ 40. **A UI a model writes at runtime is `catalog()` and `<SpecRenderer>`, and both ends of it are the app's.** `catalog({ include, exclude, styleProps })` (`@box-kite/react/catalog`) is every component and every value its props take, as JSON Schema — read off the live prop registry, so a `Box.extend()` prop or colour is in it with nothing regenerated — and `specSchema(registry)` turns that into the one schema a model generates a whole _tree_ under, for `streamObject`, a structured-output API or `z.fromJSONSchema` — exported from **both** entries, and in a route handler it is `@box-kite/react/catalog`'s you want, since `/spec` renders and carries a `use client` banner; it takes a `catalog()` as readily as a registry, so the server half needs no components. `<SpecRenderer spec registry data onAction />` renders what came back, and `createSpecRegistry({ catalog, components: { Flex, H2, Sparkline } })` is the allow-list: a name it does not hold renders nothing at all. **A node is `{ type, props, children, slots, on, repeat }`, and every field of it is checked** — a prop the component's own schema refuses is dropped (the prop, not the node), 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 yours), and there is no tag from the spec, no `eval` and no `dangerouslySetInnerHTML` anywhere in it. **Four things to know.** A spec **arrives in pieces**, so a node whose `type` has not been written yet renders nothing _and reports nothing_, while a value that is still half a string fails its schema and is dropped until it is whole — and a node still missing a prop its component cannot do without is held back with a `missing-prop`, since a required prop that is absent would be `undefined` inside the component. **A generated node writes the controlled prop and never the `default…` twin** (`layout`, not `defaultLayout`; `value`, not `defaultValue`): an uncontrolled default is read once, so the frame it first arrived whole in is the one that sticks, and a second spec rendered in the same place keeps the first one's state. A stream is not monotone either — a column's `align` passes through `"e"` on its way to `"end"`, taking the whole `def` with it — so `<SpecRenderer>` keeps the last value each node was given for a prop it cannot render without, and a heavy component is the app's to gate: point its name at a placeholder in the registry while the spec is arriving. Every node has an **error boundary of its own**, so a component that throws on props a model invented costs that node and nothing around it. `{ $data: 'stats.revenue' }`, `{ $item: 'label' }` and `{ $index: true }` read from the `data` prop **before** the schema judges them, so what the host supplied is what is validated — a path, never an expression. And everything refused is **reported** rather than swallowed: `onIssues` gets a code (`unknown-component`, `invalid-prop`, `unknown-event`…) and the path it happened at. **The props that are a shape are described by hand**, because the other half of each of them is React: `DataGrid`'s `def` (required — the columns, plus `rowKey`/`title`/`footer`/`groupBy` and the rest of the flags) and `data` (the rows, the one prop in the catalog carrying values rather than styling — `{ $data: 'orders' }` is the usual answer), `DashboardGrid`'s `layout`/`defaultLayout` (`DashboardUtils.SCHEMA`, the artifact a drag reports back) and `columns`, `Widget`'s `empty`, `ChartContainer`'s `series`. What a spec cannot write stays out: a column's own nested `columns` is a recursive schema, and `dataSource`/`onCellEdit`/`rowDetail`/`treeData` are the app's to pass beside the spec. `maxNodes` (1,000) and `maxDepth` (32) end a runaway tree, `fallback` is what stands where a node could not render (nothing, unless you say otherwise), and `renderSpec()` is the same walk with no hook in it, for a server or a test.
143
+
144
+ 41. **A dashboard people rearrange is `<DashboardGrid>` plus `<Widget>`, and its layout is JSON in cells.** `<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable><Widget id="revenue" title="Revenue"><Sparkline data={revenue} width="100%" height="100%" /></Widget></DashboardGrid>` (`components/dashboard`): the layout is `{ version, columns, items: [{ id, x, y, w, h, minW?, minH?, maxW?, maxH?, fixed? }] }` and a `Widget` fills the item carrying its `id` — one with no item is left to the grid's own flow, and outside a grid a `Widget` is a card. **Nothing is measured to lay it out**: a place is `gridColumnStart`/`gridColumnEnd`/`gridRowStart`/`gridRowEnd`, which are props, so it is a shared class and the grid renders on a server; the one inline style is the translate that keeps a dragged widget under the pointer (the `Slider` exception, for the same reason). The layout is **compacted upward**, so a widget cannot be parked in mid-air and two dashboards of the same widgets in the same places compare equal — and a widget dropped on a neighbour **takes the cell**, the neighbour being handed the row above where there is room for it and the row below otherwise, since compaction on its own reads the layout in reading order and would simply undo the drop. **`columns` is a count per container size** (`{ xs: 1, md: 6, xxl: 12 }` by default, the `cq` scale), and every narrower arrangement is that one layout _projected_ — arithmetic at render, written as a container query, so the browser picks between classes. Two traps behind that, both measured: **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**, so where a projection is showing the handles are not rendered at all. **Four things to know.** `editable` is the whole edit/view split — without it there are no handles and nothing in the tab order — and both handles are real buttons: **dragging is not a keyboard gesture, so the keyboard gets a grab**, Enter or Space to pick the widget up, the arrows to move or resize it a cell at a time (the _reading order_, so ArrowLeft moves it right in a right-to-left page), Enter to drop and Escape to put it back, every step announced in a live region that was there first. `onLayoutChange` fires on every cell a drag crosses and `onLayoutCommit` once at the end — the one to write to a server — both with a reason of `'move'` or `'resize'`, the device being in `details.event`. A `Widget` is chrome and **four states**: `loading` (bars, `aria-busy`), `error` (the message, plus a retry where there is an `onRefresh`), `empty`, and otherwise its children; its `title` is a real heading at `level` (default 3), and `name` is what the handles and the announcements call it. And **the layout is the artifact, so it is also the prompt**: `DashboardUtils.SCHEMA` is it as JSON Schema — the subset `catalog()` emits — and `DashboardUtils.parse(value)` reads one back from a database or a model, clamping what is out of range and reporting what it dropped rather than throwing. Where it is kept is the app's. Styling is the `dashboard`/`dashboard.placeholder` and `widget`/`widget.header`/`widget.label`/`widget.title`/`widget.description`/`widget.actions`/`widget.handle`/`widget.body`/`widget.message`/`widget.retry`/`widget.skeleton` tree; both handles are the one `handle` node, whose `corner` variant is the resize one, and the row height is a variable (`vars={{ 'dashboard-row': '8rem' }}`) rather than a prop.
145
+
146
+ 42. **A theme is an ancestor, and the nearest one owns the subtree.** `theme={{ dark: { bgColor: 'slate-900' } }}` nests on any element and takes everything else with it (`theme={{ dark: { hover: … } }}`, `md={{ theme: … }}`); `<Box.Theme use="global">` puts the theme class and `data-theme` on `<html>`, `use="local"` on a wrapper of its own, and `Box.useTheme()` reads and sets it. The name is any identifier, not just `light`/`dark`. **A theme inside a theme is a real boundary**: each rule is `@scope (.dark) to ([data-theme]) { :scope .className … }`, so a local `<Box.Theme theme="light">` inside a dark page is light throughout — including for a prop the inner theme never mentions, and including where the outer theme's rule carries one more pseudo-class. What ends a theme is **`data-theme`**, so a theme class written by hand (the one a prerendered shell puts on `<html>`) wants the attribute beside it; `<Box.Theme>` renders both. A theme costs no re-render — it is one class swapping on an ancestor — and it needs Chrome 118+, Safari 17.4+, Firefox 128+, below which theme rules are dropped and the unthemed values show.
147
+
148
+ 43. **An agent's turn is `<ToolCallCard>`, `<ApprovalCard>` and `<Reasoning>`.** `<ToolCallCard name="searchOrders" status="running" input={{ query: 'refunds' }} />`, `<ApprovalCard title="Refund order 4182" onDecisionChange={(decision) => respond(decision === 'approved')} />` and `<Reasoning duration={4200}>{thought}</Reasoning>` (`components/agent`) are the three parts of a turn that are not prose: what it ran, what it wants permission to do, and what it was thinking. **The status is a word, not a colour** — `pending`/`running`/`success`/`error` each carry their own label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had it — and the four map one for one onto what every runtime 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**: `AgentUtils.formatValue` (framework-free, same entry) caps the text at `valueLimit` — 20,000 characters, with a line saying how much was left — and answers a circular structure, a `BigInt` and a function, all three of which make `JSON.stringify` throw or drop them silently. **Four things to know.** `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`; the answer lands in a `role="status"` **that is in the DOM before there is anything in it**, the toaster's rule, and `busy` is the round trip to a server. The card **does not take focus** unless `autoFocus` says so, and then on _Reject_ — APG's least-destructive rule — because a turn arrives while the reader is somewhere else. A `ToolCallCard` with nothing to disclose **renders no control at all**, the header being a row of text rather than a button nobody wants to land on. And `Reasoning` is **closed by default** and opens in the same `1fr`-to-`0fr` grid an `Accordion` panel does, so nothing is measured: `streaming` only changes the words and the shimmer, and `open={streaming}` is how an app makes it follow. Styling is the `toolCall`/`approval`/`reasoning` trees, where the status is a variant on `toolCall.status`, the decision one on `approval` itself, and `toolCall.header`'s `interactive` variant is what makes a header look pressable.
149
+
150
+ 44. **What it _says_ is `<StreamingText>`, `markdownComponents` and `<Skeleton>`.** `<StreamingText text={message} streaming />` (`components/agent`) takes the text so far and fades in the part that was not there a render ago — **hand it the whole message every time, not the delta**, because knowing what is new means comparing the two. What is on the page is one settled string plus the last few runs to arrive (`window`, 8 by default; `0` turns the entrance off), so a message already whole paints at once with nothing moving — a prerendered page, a transcript read back — and one still arriving costs the same at the ten-thousandth token as at the first. The entrance is `@starting-style` rather than a keyframe, so it rides `--transitionTime` and stops under `prefers-reduced-motion` on its own, and the element is deliberately **not** a live region: one announcing every token would read the message out a word at a time and again when it finished, so it carries `aria-busy` and the transcript does the announcing. **The markdown bridge is a map, not a component**: `markdownComponents` (`components/markdown`) is the `components` map `react-markdown`, Streamdown and everything on that shape already take, so the parser stays the app's and there is no stylesheet, no Tailwind `@source` line and no design tokens to declare — wrap the renderer in `<Box component="markdown">` and the nodes inherit its size and colour. It is a **constant**, and that matters while streaming: a map built inside render is a new set of component _types_ every token, which React answers by unmounting the whole message, so override by spreading at module scope (`{ ...markdownComponents, h1: MyHeading }`). Whether a URL is safe stays the renderer's `urlTransform`, since the href is parsed before a component is called. **`<Skeleton lines={3} />`** (`components/skeleton`) is the placeholder — `circle` for an avatar, the gloss a named duration so it stops itself — and it is `aria-hidden` unless given a `label`, which makes it one `role="status"`: put that on the skeleton standing for a region, never on every bar. Styling is `streamingText` (`segment`, `caret`), `skeleton` (`bar` with `short`/`circle`, `gloss`) and `markdown` (`heading`, `paragraph`, `link`, `list`, `item`, `quote`, `code`, `codeBlock`, `rule`, `image`, `table`, `row`, `cell`, `inline`, `checkbox`).
151
+
152
+ 45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.
153
+
138
154
  Full reference: `references/` beside this file, and `docs/props.md` inside the package.
@@ -21,6 +21,11 @@ Which component replaces which `<Box tag>`, and the three that carry a pattern o
21
21
  | a value dragged along a track | `<Slider>` | `components/slider` |
22
22
  | how far a task has got | `<Progress>` | `components/progress` |
23
23
  | a message sent from anywhere | `<Toaster>` + `toast()` | `components/toaster` |
24
+ | widgets arranged, dragged, resized | `<DashboardGrid>` + `<Widget>` | `components/dashboard` |
25
+ | a tool call, a thought, an approval | `<ToolCallCard>`/`<Reasoning>`/… | `components/agent` |
26
+ | text arriving token by token | `<StreamingText>` | `components/agent` |
27
+ | a model's markdown, themed | `markdownComponents` (a map) | `components/markdown` |
28
+ | a placeholder while it loads | `<Skeleton>` | `components/skeleton` |
24
29
  | a lucide/Tabler icon, styled | `<Icon>` | `components/icon` |
25
30
  | a sparkline, ring, gauge or donut | `<Sparkline>`/`<ProgressRing>`/… | `components/chart` |
26
31
  | a themed Recharts (or any) chart | `<ChartContainer>` | `components/chart` |
@@ -222,14 +227,14 @@ import DataGrid from '@box-kite/react/components/dataGrid';
222
227
  />;
223
228
  ```
224
229
 
225
- **DataGridProps**: `data`, `def`, `component` (default `'datagrid'`), `loading`, `filters` (predicate[]), `page`/`onPageChange`, `onSortChange`, `onServerStateChange` (`{ page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }`), `onSelectionChange` (`{ action, selectedRowKeys, affectedRowKeys, isAllSelected }`), `expandedRowKeys`/`onExpandedRowKeysChange`, `globalFilterValue`/`onGlobalFilterChange`, `columnFilters`/`onColumnFiltersChange`.
230
+ **DataGridProps**: `data`, `def`, `component` (default `'datagrid'`), `loading`, `filters` (predicate[]), `page`/`onPageChange`, `onSortChange`, `onServerStateChange` (`{ page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }`), `onSelectionChange` (`{ action, selectedRowKeys, affectedRowKeys, isAllSelected }`), `expandedRowKeys`/`onExpandedRowKeysChange`, `globalFilterValue`/`onGlobalFilterChange`, `columnFilters`/`onColumnFiltersChange`, `onCellEditsChange` (`edit`/`paste`/`clear`), `onRangeChange` (`(range, { reason })` with `select`/`extend`/`clear`; `range` is `{ startRow, endRow, columns, values() }` or `undefined`), `onPaste` (`{ range, applied, rejected, skipped }`, once per Ctrl+V).
226
231
 
227
- **GridDefinition**: `columns` (required), `rowKey`, `rowHeight` (px, default 48), `visibleRowsCount` (number/`'all'`), `showRowNumber` (bool/`{ pinned?, width? }`), `rowSelection` (bool/`{ pinned? }`), `rowDetail` (`{ content, height?, expandOnRowClick?, pinned?, expandColumnWidth?, expandColumnHeader? }`), `pagination` (`{ totalCount, pageSize? }`), `topBar`/`bottomBar`, `title`/`topBarContent`, `globalFilter`, `globalFilterKeys`, `sortable`/`resizable` (default true), `contextMenu` (bool/`{ sort?, pin?, group? }`, default true), `resizerStyle` (`'visible'`/`'hover'`/`'hidden'`), `noDataComponent`.
232
+ **GridDefinition**: `columns` (required), `rowKey`, `rowHeight` (px, default 48), `visibleRowsCount` (number/`'all'`), `showRowNumber` (bool/`{ pinned?, width? }`), `rowSelection` (bool/`{ pinned? }`), `rowDetail` (`{ content, height?, expandOnRowClick?, pinned?, expandColumnWidth?, expandColumnHeader? }`), `pagination` (`{ totalCount, pageSize? }`), `topBar`/`bottomBar`, `title`/`topBarContent`, `globalFilter`, `globalFilterKeys`, `sortable`/`resizable` (default true), `contextMenu` (bool/`{ sort?, pin?, group? }`, default true), `resizerStyle` (`'visible'`/`'hover'`/`'hidden'`), `noDataComponent`, `editable`/`onCellEdit`, `tabNavigation` (`'none'` default — APG's single tab stop — or `'cells'`, the spreadsheet reading), `rangeSelection` (a drag or Shift+arrow marks a block of cells; `Ctrl+C` copies it as TSV and `Ctrl+V` fills it back through `onCellEdit`, one judgement per cell — both need no opt-in for the current cell alone).
228
233
 
229
234
  **ColumnType**: `key`, `header`, `width` (px, default 200), `align` (`'start'`/`'end'`/`'center'`, or the physical `'left'`/`'right'`), `pin` (`'START'`/`'END'` — an edge of the **inline axis**, so a pinned column holds under either `dir`; `'LEFT'`/`'RIGHT'` are the older spelling of the same two), `columns` (grouped headers), `Cell` (`({ cell }) => ReactNode`), `sortable`/`resizable` (override grid), `flexible`, `filterable` (`true`=text, `{ type: 'number' }`, `{ type: 'multiselect' }`), `contextMenu` (override grid).
230
235
 
231
236
  **Server-side**: `def={{ pagination: { totalCount }, bottomBar: true }}` + `page={page}` + `onServerStateChange={fetchData}`.
232
237
 
233
- **Accessibility (A7, A10)**: the APG grid pattern, over a virtualized body — supplied whole, so add no roles and no `tabIndex`. The scrolling element is `role="grid"` (header and body are `rowgroup`s, rows `role="row"`, cells `role="gridcell"`/`columnheader`, scroll spacers `presentation`). `aria-rowcount`/`-colcount` describe the _whole_ grid and `aria-rowindex`/`-colindex` place each rendered cell in it, header rows first. Also `aria-sort` on sortable headers, `aria-selected` on rows only with `rowSelection`, `aria-busy` while `loading`, `aria-expanded` on group and expanded rows, and a live region announcing the selection count. Keyboard: one cell in the tab order; arrows move it, Home/End along the row, Ctrl+Home/End to the corners (scrolling to unrendered rows), PageUp/Down by a screenful, Enter/Space sorts a header or steps into the cell’s control, F2 always steps in, Escape steps back out. Down/Up keep the _column_ rather than the cell ordinal, so they land under where they started even through a grouped header. The column resizer is APG's window splitter — `role="separator"` with `aria-valuenow`/`-valuemin`/`-valuemax` in pixels; Tab or F2 reaches it, the arrows move it 16px, Home/End go to the narrowest the grid allows and to the grid's own width. Every control the grid draws names itself after what it acts on ("Select row 4", "Column options for Age"); the column menu is the APG menu button. **Give it `def.title`** — a grid is not named by its rows. Known gap: Tab does not yet stay inside the grid.
238
+ **Accessibility (A7, A10)**: the APG grid pattern, over a virtualized body — supplied whole, so add no roles and no `tabIndex`. The scrolling element is `role="grid"` (header and body are `rowgroup`s, rows `role="row"`, cells `role="gridcell"`/`columnheader`, scroll spacers `presentation`). `aria-rowcount`/`-colcount` describe the _whole_ grid and `aria-rowindex`/`-colindex` place each rendered cell in it, header rows first. Also `aria-sort` on sortable headers, `aria-selected` on rows only with `rowSelection`, `aria-busy` while `loading`, `aria-expanded` on group and expanded rows, and a live region announcing the selection count. Keyboard: one cell in the tab order; arrows move it, Home/End along the row, Ctrl+Home/End to the corners (scrolling to unrendered rows), PageUp/Down by a screenful, Enter/Space sorts a header or steps into the cell’s control, F2 always steps in, Escape steps back out. Down/Up keep the _column_ rather than the cell ordinal, so they land under where they started even through a grouped header. The column resizer is APG's window splitter — `role="separator"` with `aria-valuenow`/`-valuemin`/`-valuemax` in pixels; Tab or F2 reaches it, the arrows move it 16px, Home/End go to the narrowest the grid allows and to the grid's own width. Space on any cell selects its row, and on the header cell the select-all box sits in it toggles all of them. Every cell carries the **current-cell mark**, which survives the grid losing focus so `Ctrl+C` has something to act on; with `def.rangeSelection` the cells of a marked block carry `aria-selected` and the grid is `aria-multiselectable`, while a lone current cell carries neither. Every control the grid draws names itself after what it acts on ("Select row 4", "Column options for Age"); the column menu is the APG menu button. **Give it `def.title`** — a grid is not named by its rows. Every widget a cell holds is out of the page tab sequence, so the grid is one tab stop: Enter or F2 steps in, Tab walks the widgets **within that cell** and wraps, and Escape is the way out.
234
239
 
235
240
  **Style tree**: `datagrid` > `content`, `topBar` > (`globalFilter` > `stats`, `columnGroups` > `icon`|`separator`|`item` > `icon`, `columnVisibility` > `badge`), `filter` > `cell` > `input`, `header` > `cell` > (`contextMenu` > `icon`|`tooltip` > `item` > `icon`|`separator`, `resizer`), `body` > (`cell` > `text`|`rowDetail`, `row`, `groupRow` > `expandButton`, `detailRow`, `empty`), `emptyColumns`, `bottomBar` > (`info`, `clearFilters`, `pagination` > `button`|`info`).
@@ -25,3 +25,26 @@ The recipes, plus the parts of the library that are not props: server rendering,
25
25
  **Menu** (`components/menu`): APG's menu button on the same Popover API, and a submenu is a popover nested inside it. `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>`. Parts: `Menu.Item`, `Menu.CheckboxItem` (`checked`/`defaultChecked`/`onCheckedChange`), `Menu.RadioGroup` (`value`/`defaultValue`/`onValueChange`) + `Menu.RadioItem value`, `Menu.Group label` (which names the group through `aria-labelledby`), `Menu.Separator`, and `Menu.Sub label`, which renders its own `menuitem` with `aria-haspopup="menu"` and `aria-expanded` plus the menu beside it. Props on the menu: `trigger`, `open`/`defaultOpen`/`onOpenChange(open, { reason })` with `select`|`tab` beside `trigger`|`escape`|`outside-pointer`|`imperative`, `label`/`labelledBy` (left out, the menu takes its trigger's name — APG's rule), and the four placement props `side`/`align`/`offset`/`flip`, which `Menu.Sub` takes too (`side="end"`, `offset={0}` by default, so a submenu abuts its menu). The platform supplies the top layer, light dismiss — Escape closing the innermost menu first — and the toggle; the component supplies the roles, the keys (Down/Up wrapping, Home/End, typeahead where a letter again cycles, Right/Left for a submenu in **reading order**, so they swap in a right-to-left menu) and the focus moves the browser does not make: into the first item on open, and back onto a submenu's own item when it closes. Four things to know: a `disabled` item is `aria-disabled` and **stays focusable**, as APG asks; `Menu.Item` closes the menu on select while a checkbox or radio item does not, and `closeOnSelect` swaps either default; the menu is **always rendered** (closed is `display: none`), so the exit is a CSS transition and never `<Presence>`, and expensive items want gating with `{open ? … : null}`; and it inherits the top layer's one cost, keeping the side it opened on (see `Overlay`). The semantic `<menu>` element is `MenuList` from `components/semantics` now, since two exports named `Menu` are one import away from the wrong component.
26
26
 
27
27
  **Tooltip** (`components/tooltip`): the APG pattern on that layer. `<Tooltip content="Deletes the row">{(trigger) => <Button {...trigger}>Delete</Button>}</Tooltip>` — the child is a render prop because `aria-describedby` has to land on the control itself; the bag is `{ ref, props }`, so one spread on a Box component, or `<button ref={trigger.ref} {...trigger.props}>` on a plain one. The `ref` is what positions the bubble under the trigger. Gives you `role="tooltip"`, the describedby wiring while open, hover **and** focus opening (`openDelay` 300 ms, focus ignores it), Escape with focus left in place, and WCAG 1.4.13: the pointer can travel onto the bubble (`closeDelay` 150 ms) and nothing hides it on a timer. `open`/`defaultOpen`/`onOpenChange(open, { reason })` with reasons `hover`|`focus`|`pointer-leave`|`blur`|`escape`; other Box props style the bubble.
28
+
29
+ ## Another runtime's agent shapes
30
+
31
+ `@box-kite/react/interop` maps the agentic ecosystem's shapes onto this library's, and imports none of
32
+ them. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit into
33
+ `{ kind: 'call' | 'approval', status, decision, input, output, error }` — the words `<ToolCallCard>` and
34
+ `<ApprovalCard>` already take. **A decision is not a stage a call passes through**, so AI SDK's
35
+ `approval-requested`/`approval-responded`, assistant-ui's `requires-action` and the `executing`
36
+ CopilotKit hands a `respond` with are all the second component. **AG-UI reports events, not parts**, so
37
+ it folds: `applyToolEvent(parts, event)`.
38
+
39
+ ```tsx
40
+ const mapped = toolPart(part);
41
+
42
+ mapped?.kind === 'approval' ? <ApprovalCard title="Refund 4182" onDecisionChange={answer} /> : <ToolCallCard {...mapped} />;
43
+ ```
44
+
45
+ A2UI is a flat adjacency list rather than a tree, so it has real code behind it: `a2uiApply(state, message)`
46
+ folds a v0.8 or v0.9 stream, `a2uiSurface(state)` picks one out, and `a2uiToSpec(surface, { catalog })`
47
+ walks it into what `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`
48
+ and its `children: { componentId, path }` template is already `repeat`. `a2uiCatalog(catalog())` is the
49
+ other direction, and `a2uiComponentSchema(document, name)` is one component of it with the document's
50
+ `$defs` attached, which is what `z.fromJSONSchema` needs. Full reference: `docs/interop.md`.
@@ -70,4 +70,20 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
70
70
 
71
71
  37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox`, everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the four (`text`/`number`/`checkbox`/`select`) or configures it (`{ type, options?, placeholder?, step?, min?, max? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
72
72
 
73
+ 38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
74
+
75
+ 39. **The block goes back in with Ctrl+V, one judgement per cell.** A paste 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 there is no second validator, no second event and no second way of writing a value; accepted cells land in the same stream a typed one does, with a `reason` of `'paste'`. It needs no opt-in, for the reason Ctrl+C does not: `def.rangeSelection` only gives it a bigger target. **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. **Four things to know.** **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 (`datagrid.body.cell`'s `isInvalid`, with no editor open behind it) and report `aria-invalid`, so the cells say which ones and **`onPaste({ range, applied, rejected, skipped })`** says why — once for the whole block, after the values have landed, so `range.values()` reads what the grid now shows. **The clipboard carries no types, so the cell says how to read the text**: a number column parses it and refuses what is not a number, a checkbox takes `true`/`false` (and `1`/`0`), and a `select` keeps to its own options and holds the option's value rather than its spelling. **A value that did not change is not an edit at all**, so pasting a column back over itself — the way a spreadsheet is used — costs nothing and reports nothing. And **a paste that reaches an open editor belongs to the editor**: that is a value being typed, not a block being filled, so the grid hands it back rather than filling the cells under it; a cell nothing can be written to is skipped before anything is asked about it, and counted.
76
+
77
+ 40. **A UI a model writes at runtime is `catalog()` and `<SpecRenderer>`, and both ends of it are the app's.** `catalog({ include, exclude, styleProps })` (`@box-kite/react/catalog`) is every component and every value its props take, as JSON Schema — read off the live prop registry, so a `Box.extend()` prop or colour is in it with nothing regenerated — and `specSchema(registry)` turns that into the one schema a model generates a whole _tree_ under, for `streamObject`, a structured-output API or `z.fromJSONSchema` — exported from **both** entries, and in a route handler it is `@box-kite/react/catalog`'s you want, since `/spec` renders and carries a `use client` banner; it takes a `catalog()` as readily as a registry, so the server half needs no components. `<SpecRenderer spec registry data onAction />` renders what came back, and `createSpecRegistry({ catalog, components: { Flex, H2, Sparkline } })` is the allow-list: a name it does not hold renders nothing at all. **A node is `{ type, props, children, slots, on, repeat }`, and every field of it is checked** — a prop the component's own schema refuses is dropped (the prop, not the node), 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 yours), and there is no tag from the spec, no `eval` and no `dangerouslySetInnerHTML` anywhere in it. **Four things to know.** A spec **arrives in pieces**, so a node whose `type` has not been written yet renders nothing _and reports nothing_, while a value that is still half a string fails its schema and is dropped until it is whole — and a node still missing a prop its component cannot do without is held back with a `missing-prop`, since a required prop that is absent would be `undefined` inside the component. **A generated node writes the controlled prop and never the `default…` twin** (`layout`, not `defaultLayout`; `value`, not `defaultValue`): an uncontrolled default is read once, so the frame it first arrived whole in is the one that sticks, and a second spec rendered in the same place keeps the first one's state. A stream is not monotone either — a column's `align` passes through `"e"` on its way to `"end"`, taking the whole `def` with it — so `<SpecRenderer>` keeps the last value each node was given for a prop it cannot render without, and a heavy component is the app's to gate: point its name at a placeholder in the registry while the spec is arriving. Every node has an **error boundary of its own**, so a component that throws on props a model invented costs that node and nothing around it. `{ $data: 'stats.revenue' }`, `{ $item: 'label' }` and `{ $index: true }` read from the `data` prop **before** the schema judges them, so what the host supplied is what is validated — a path, never an expression. And everything refused is **reported** rather than swallowed: `onIssues` gets a code (`unknown-component`, `invalid-prop`, `unknown-event`…) and the path it happened at. **The props that are a shape are described by hand**, because the other half of each of them is React: `DataGrid`'s `def` (required — the columns, plus `rowKey`/`title`/`footer`/`groupBy` and the rest of the flags) and `data` (the rows, the one prop in the catalog carrying values rather than styling — `{ $data: 'orders' }` is the usual answer), `DashboardGrid`'s `layout`/`defaultLayout` (`DashboardUtils.SCHEMA`, the artifact a drag reports back) and `columns`, `Widget`'s `empty`, `ChartContainer`'s `series`. What a spec cannot write stays out: a column's own nested `columns` is a recursive schema, and `dataSource`/`onCellEdit`/`rowDetail`/`treeData` are the app's to pass beside the spec. `maxNodes` (1,000) and `maxDepth` (32) end a runaway tree, `fallback` is what stands where a node could not render (nothing, unless you say otherwise), and `renderSpec()` is the same walk with no hook in it, for a server or a test.
78
+
79
+ 41. **A dashboard people rearrange is `<DashboardGrid>` plus `<Widget>`, and its layout is JSON in cells.** `<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable><Widget id="revenue" title="Revenue"><Sparkline data={revenue} width="100%" height="100%" /></Widget></DashboardGrid>` (`components/dashboard`): the layout is `{ version, columns, items: [{ id, x, y, w, h, minW?, minH?, maxW?, maxH?, fixed? }] }` and a `Widget` fills the item carrying its `id` — one with no item is left to the grid's own flow, and outside a grid a `Widget` is a card. **Nothing is measured to lay it out**: a place is `gridColumnStart`/`gridColumnEnd`/`gridRowStart`/`gridRowEnd`, which are props, so it is a shared class and the grid renders on a server; the one inline style is the translate that keeps a dragged widget under the pointer (the `Slider` exception, for the same reason). The layout is **compacted upward**, so a widget cannot be parked in mid-air and two dashboards of the same widgets in the same places compare equal — and a widget dropped on a neighbour **takes the cell**, the neighbour being handed the row above where there is room for it and the row below otherwise, since compaction on its own reads the layout in reading order and would simply undo the drop. **`columns` is a count per container size** (`{ xs: 1, md: 6, xxl: 12 }` by default, the `cq` scale), and every narrower arrangement is that one layout _projected_ — arithmetic at render, written as a container query, so the browser picks between classes. Two traps behind that, both measured: **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**, so where a projection is showing the handles are not rendered at all. **Four things to know.** `editable` is the whole edit/view split — without it there are no handles and nothing in the tab order — and both handles are real buttons: **dragging is not a keyboard gesture, so the keyboard gets a grab**, Enter or Space to pick the widget up, the arrows to move or resize it a cell at a time (the _reading order_, so ArrowLeft moves it right in a right-to-left page), Enter to drop and Escape to put it back, every step announced in a live region that was there first. `onLayoutChange` fires on every cell a drag crosses and `onLayoutCommit` once at the end — the one to write to a server — both with a reason of `'move'` or `'resize'`, the device being in `details.event`. A `Widget` is chrome and **four states**: `loading` (bars, `aria-busy`), `error` (the message, plus a retry where there is an `onRefresh`), `empty`, and otherwise its children; its `title` is a real heading at `level` (default 3), and `name` is what the handles and the announcements call it. And **the layout is the artifact, so it is also the prompt**: `DashboardUtils.SCHEMA` is it as JSON Schema — the subset `catalog()` emits — and `DashboardUtils.parse(value)` reads one back from a database or a model, clamping what is out of range and reporting what it dropped rather than throwing. Where it is kept is the app's. Styling is the `dashboard`/`dashboard.placeholder` and `widget`/`widget.header`/`widget.label`/`widget.title`/`widget.description`/`widget.actions`/`widget.handle`/`widget.body`/`widget.message`/`widget.retry`/`widget.skeleton` tree; both handles are the one `handle` node, whose `corner` variant is the resize one, and the row height is a variable (`vars={{ 'dashboard-row': '8rem' }}`) rather than a prop.
80
+
81
+ 42. **A theme is an ancestor, and the nearest one owns the subtree.** `theme={{ dark: { bgColor: 'slate-900' } }}` nests on any element and takes everything else with it (`theme={{ dark: { hover: … } }}`, `md={{ theme: … }}`); `<Box.Theme use="global">` puts the theme class and `data-theme` on `<html>`, `use="local"` on a wrapper of its own, and `Box.useTheme()` reads and sets it. The name is any identifier, not just `light`/`dark`. **A theme inside a theme is a real boundary**: each rule is `@scope (.dark) to ([data-theme]) { :scope .className … }`, so a local `<Box.Theme theme="light">` inside a dark page is light throughout — including for a prop the inner theme never mentions, and including where the outer theme's rule carries one more pseudo-class. What ends a theme is **`data-theme`**, so a theme class written by hand (the one a prerendered shell puts on `<html>`) wants the attribute beside it; `<Box.Theme>` renders both. A theme costs no re-render — it is one class swapping on an ancestor — and it needs Chrome 118+, Safari 17.4+, Firefox 128+, below which theme rules are dropped and the unthemed values show.
82
+
83
+ 43. **An agent's turn is `<ToolCallCard>`, `<ApprovalCard>` and `<Reasoning>`.** `<ToolCallCard name="searchOrders" status="running" input={{ query: 'refunds' }} />`, `<ApprovalCard title="Refund order 4182" onDecisionChange={(decision) => respond(decision === 'approved')} />` and `<Reasoning duration={4200}>{thought}</Reasoning>` (`components/agent`) are the three parts of a turn that are not prose: what it ran, what it wants permission to do, and what it was thinking. **The status is a word, not a colour** — `pending`/`running`/`success`/`error` each carry their own label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had it — and the four map one for one onto what every runtime 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**: `AgentUtils.formatValue` (framework-free, same entry) caps the text at `valueLimit` — 20,000 characters, with a line saying how much was left — and answers a circular structure, a `BigInt` and a function, all three of which make `JSON.stringify` throw or drop them silently. **Four things to know.** `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`; the answer lands in a `role="status"` **that is in the DOM before there is anything in it**, the toaster's rule, and `busy` is the round trip to a server. The card **does not take focus** unless `autoFocus` says so, and then on _Reject_ — APG's least-destructive rule — because a turn arrives while the reader is somewhere else. A `ToolCallCard` with nothing to disclose **renders no control at all**, the header being a row of text rather than a button nobody wants to land on. And `Reasoning` is **closed by default** and opens in the same `1fr`-to-`0fr` grid an `Accordion` panel does, so nothing is measured: `streaming` only changes the words and the shimmer, and `open={streaming}` is how an app makes it follow. Styling is the `toolCall`/`approval`/`reasoning` trees, where the status is a variant on `toolCall.status`, the decision one on `approval` itself, and `toolCall.header`'s `interactive` variant is what makes a header look pressable.
84
+
85
+ 44. **What it _says_ is `<StreamingText>`, `markdownComponents` and `<Skeleton>`.** `<StreamingText text={message} streaming />` (`components/agent`) takes the text so far and fades in the part that was not there a render ago — **hand it the whole message every time, not the delta**, because knowing what is new means comparing the two. What is on the page is one settled string plus the last few runs to arrive (`window`, 8 by default; `0` turns the entrance off), so a message already whole paints at once with nothing moving — a prerendered page, a transcript read back — and one still arriving costs the same at the ten-thousandth token as at the first. The entrance is `@starting-style` rather than a keyframe, so it rides `--transitionTime` and stops under `prefers-reduced-motion` on its own, and the element is deliberately **not** a live region: one announcing every token would read the message out a word at a time and again when it finished, so it carries `aria-busy` and the transcript does the announcing. **The markdown bridge is a map, not a component**: `markdownComponents` (`components/markdown`) is the `components` map `react-markdown`, Streamdown and everything on that shape already take, so the parser stays the app's and there is no stylesheet, no Tailwind `@source` line and no design tokens to declare — wrap the renderer in `<Box component="markdown">` and the nodes inherit its size and colour. It is a **constant**, and that matters while streaming: a map built inside render is a new set of component _types_ every token, which React answers by unmounting the whole message, so override by spreading at module scope (`{ ...markdownComponents, h1: MyHeading }`). Whether a URL is safe stays the renderer's `urlTransform`, since the href is parsed before a component is called. **`<Skeleton lines={3} />`** (`components/skeleton`) is the placeholder — `circle` for an avatar, the gloss a named duration so it stops itself — and it is `aria-hidden` unless given a `label`, which makes it one `role="status"`: put that on the skeleton standing for a region, never on every bar. Styling is `streamingText` (`segment`, `caret`), `skeleton` (`bar` with `short`/`circle`, `gloss`) and `markdown` (`heading`, `paragraph`, `link`, `list`, `item`, `quote`, `code`, `codeBlock`, `rule`, `image`, `table`, `row`, `cell`, `inline`, `checkbox`).
86
+
87
+ 45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.
88
+
73
89
  Full reference: `node_modules/@box-kite/react/docs/props.md` in the project, or https://www.box-kite.dev/props.md — every prop, the CSS it writes and one measured example.
package/AGENTS.md CHANGED
@@ -11,6 +11,9 @@ docs/props.md every prop, the CSS it writes and one measured examp
11
11
  docs/components.md every component, its import specifier and whether it renders on a server
12
12
  docs/a11y.md the behaviour hooks (`@box-kite/react/a11y`) for a pattern this library does not ship
13
13
  docs/anchor.md where a floating layer goes (`@box-kite/react/anchor`), in CSS and in the measured fallback
14
+ docs/catalog.md what a generated UI may build (`@box-kite/react/catalog`) and how it renders (`@box-kite/react/spec`)
15
+ docs/interop.md the agentic runtimes (`@box-kite/react/interop`): AI SDK, assistant-ui, CopilotKit, A2UI and AG-UI, each verified against its own package
16
+ docs/mcp.md the MCP server (`npx @box-kite/mcp`), whose `check_styles` tool is the only way to be sure a value works
14
17
  BOX_KITE_AI_CONTEXT.md the long-form reference
15
18
  .claude/skills/box-kite/ the rules as an installable skill, with four references
16
19
  .cursor/rules/box-kite.mdc the rules as a Cursor rule
@@ -99,4 +102,20 @@ Every value there is a prop, and every prop value becomes one class the engine i
99
102
 
100
103
  37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox`, everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the four (`text`/`number`/`checkbox`/`select`) or configures it (`{ type, options?, placeholder?, step?, min?, max? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
101
104
 
105
+ 38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
106
+
107
+ 39. **The block goes back in with Ctrl+V, one judgement per cell.** A paste 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 there is no second validator, no second event and no second way of writing a value; accepted cells land in the same stream a typed one does, with a `reason` of `'paste'`. It needs no opt-in, for the reason Ctrl+C does not: `def.rangeSelection` only gives it a bigger target. **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. **Four things to know.** **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 (`datagrid.body.cell`'s `isInvalid`, with no editor open behind it) and report `aria-invalid`, so the cells say which ones and **`onPaste({ range, applied, rejected, skipped })`** says why — once for the whole block, after the values have landed, so `range.values()` reads what the grid now shows. **The clipboard carries no types, so the cell says how to read the text**: a number column parses it and refuses what is not a number, a checkbox takes `true`/`false` (and `1`/`0`), and a `select` keeps to its own options and holds the option's value rather than its spelling. **A value that did not change is not an edit at all**, so pasting a column back over itself — the way a spreadsheet is used — costs nothing and reports nothing. And **a paste that reaches an open editor belongs to the editor**: that is a value being typed, not a block being filled, so the grid hands it back rather than filling the cells under it; a cell nothing can be written to is skipped before anything is asked about it, and counted.
108
+
109
+ 40. **A UI a model writes at runtime is `catalog()` and `<SpecRenderer>`, and both ends of it are the app's.** `catalog({ include, exclude, styleProps })` (`@box-kite/react/catalog`) is every component and every value its props take, as JSON Schema — read off the live prop registry, so a `Box.extend()` prop or colour is in it with nothing regenerated — and `specSchema(registry)` turns that into the one schema a model generates a whole _tree_ under, for `streamObject`, a structured-output API or `z.fromJSONSchema` — exported from **both** entries, and in a route handler it is `@box-kite/react/catalog`'s you want, since `/spec` renders and carries a `use client` banner; it takes a `catalog()` as readily as a registry, so the server half needs no components. `<SpecRenderer spec registry data onAction />` renders what came back, and `createSpecRegistry({ catalog, components: { Flex, H2, Sparkline } })` is the allow-list: a name it does not hold renders nothing at all. **A node is `{ type, props, children, slots, on, repeat }`, and every field of it is checked** — a prop the component's own schema refuses is dropped (the prop, not the node), 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 yours), and there is no tag from the spec, no `eval` and no `dangerouslySetInnerHTML` anywhere in it. **Four things to know.** A spec **arrives in pieces**, so a node whose `type` has not been written yet renders nothing _and reports nothing_, while a value that is still half a string fails its schema and is dropped until it is whole — and a node still missing a prop its component cannot do without is held back with a `missing-prop`, since a required prop that is absent would be `undefined` inside the component. **A generated node writes the controlled prop and never the `default…` twin** (`layout`, not `defaultLayout`; `value`, not `defaultValue`): an uncontrolled default is read once, so the frame it first arrived whole in is the one that sticks, and a second spec rendered in the same place keeps the first one's state. A stream is not monotone either — a column's `align` passes through `"e"` on its way to `"end"`, taking the whole `def` with it — so `<SpecRenderer>` keeps the last value each node was given for a prop it cannot render without, and a heavy component is the app's to gate: point its name at a placeholder in the registry while the spec is arriving. Every node has an **error boundary of its own**, so a component that throws on props a model invented costs that node and nothing around it. `{ $data: 'stats.revenue' }`, `{ $item: 'label' }` and `{ $index: true }` read from the `data` prop **before** the schema judges them, so what the host supplied is what is validated — a path, never an expression. And everything refused is **reported** rather than swallowed: `onIssues` gets a code (`unknown-component`, `invalid-prop`, `unknown-event`…) and the path it happened at. **The props that are a shape are described by hand**, because the other half of each of them is React: `DataGrid`'s `def` (required — the columns, plus `rowKey`/`title`/`footer`/`groupBy` and the rest of the flags) and `data` (the rows, the one prop in the catalog carrying values rather than styling — `{ $data: 'orders' }` is the usual answer), `DashboardGrid`'s `layout`/`defaultLayout` (`DashboardUtils.SCHEMA`, the artifact a drag reports back) and `columns`, `Widget`'s `empty`, `ChartContainer`'s `series`. What a spec cannot write stays out: a column's own nested `columns` is a recursive schema, and `dataSource`/`onCellEdit`/`rowDetail`/`treeData` are the app's to pass beside the spec. `maxNodes` (1,000) and `maxDepth` (32) end a runaway tree, `fallback` is what stands where a node could not render (nothing, unless you say otherwise), and `renderSpec()` is the same walk with no hook in it, for a server or a test.
110
+
111
+ 41. **A dashboard people rearrange is `<DashboardGrid>` plus `<Widget>`, and its layout is JSON in cells.** `<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable><Widget id="revenue" title="Revenue"><Sparkline data={revenue} width="100%" height="100%" /></Widget></DashboardGrid>` (`components/dashboard`): the layout is `{ version, columns, items: [{ id, x, y, w, h, minW?, minH?, maxW?, maxH?, fixed? }] }` and a `Widget` fills the item carrying its `id` — one with no item is left to the grid's own flow, and outside a grid a `Widget` is a card. **Nothing is measured to lay it out**: a place is `gridColumnStart`/`gridColumnEnd`/`gridRowStart`/`gridRowEnd`, which are props, so it is a shared class and the grid renders on a server; the one inline style is the translate that keeps a dragged widget under the pointer (the `Slider` exception, for the same reason). The layout is **compacted upward**, so a widget cannot be parked in mid-air and two dashboards of the same widgets in the same places compare equal — and a widget dropped on a neighbour **takes the cell**, the neighbour being handed the row above where there is room for it and the row below otherwise, since compaction on its own reads the layout in reading order and would simply undo the drop. **`columns` is a count per container size** (`{ xs: 1, md: 6, xxl: 12 }` by default, the `cq` scale), and every narrower arrangement is that one layout _projected_ — arithmetic at render, written as a container query, so the browser picks between classes. Two traps behind that, both measured: **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**, so where a projection is showing the handles are not rendered at all. **Four things to know.** `editable` is the whole edit/view split — without it there are no handles and nothing in the tab order — and both handles are real buttons: **dragging is not a keyboard gesture, so the keyboard gets a grab**, Enter or Space to pick the widget up, the arrows to move or resize it a cell at a time (the _reading order_, so ArrowLeft moves it right in a right-to-left page), Enter to drop and Escape to put it back, every step announced in a live region that was there first. `onLayoutChange` fires on every cell a drag crosses and `onLayoutCommit` once at the end — the one to write to a server — both with a reason of `'move'` or `'resize'`, the device being in `details.event`. A `Widget` is chrome and **four states**: `loading` (bars, `aria-busy`), `error` (the message, plus a retry where there is an `onRefresh`), `empty`, and otherwise its children; its `title` is a real heading at `level` (default 3), and `name` is what the handles and the announcements call it. And **the layout is the artifact, so it is also the prompt**: `DashboardUtils.SCHEMA` is it as JSON Schema — the subset `catalog()` emits — and `DashboardUtils.parse(value)` reads one back from a database or a model, clamping what is out of range and reporting what it dropped rather than throwing. Where it is kept is the app's. Styling is the `dashboard`/`dashboard.placeholder` and `widget`/`widget.header`/`widget.label`/`widget.title`/`widget.description`/`widget.actions`/`widget.handle`/`widget.body`/`widget.message`/`widget.retry`/`widget.skeleton` tree; both handles are the one `handle` node, whose `corner` variant is the resize one, and the row height is a variable (`vars={{ 'dashboard-row': '8rem' }}`) rather than a prop.
112
+
113
+ 42. **A theme is an ancestor, and the nearest one owns the subtree.** `theme={{ dark: { bgColor: 'slate-900' } }}` nests on any element and takes everything else with it (`theme={{ dark: { hover: … } }}`, `md={{ theme: … }}`); `<Box.Theme use="global">` puts the theme class and `data-theme` on `<html>`, `use="local"` on a wrapper of its own, and `Box.useTheme()` reads and sets it. The name is any identifier, not just `light`/`dark`. **A theme inside a theme is a real boundary**: each rule is `@scope (.dark) to ([data-theme]) { :scope .className … }`, so a local `<Box.Theme theme="light">` inside a dark page is light throughout — including for a prop the inner theme never mentions, and including where the outer theme's rule carries one more pseudo-class. What ends a theme is **`data-theme`**, so a theme class written by hand (the one a prerendered shell puts on `<html>`) wants the attribute beside it; `<Box.Theme>` renders both. A theme costs no re-render — it is one class swapping on an ancestor — and it needs Chrome 118+, Safari 17.4+, Firefox 128+, below which theme rules are dropped and the unthemed values show.
114
+
115
+ 43. **An agent's turn is `<ToolCallCard>`, `<ApprovalCard>` and `<Reasoning>`.** `<ToolCallCard name="searchOrders" status="running" input={{ query: 'refunds' }} />`, `<ApprovalCard title="Refund order 4182" onDecisionChange={(decision) => respond(decision === 'approved')} />` and `<Reasoning duration={4200}>{thought}</Reasoning>` (`components/agent`) are the three parts of a turn that are not prose: what it ran, what it wants permission to do, and what it was thinking. **The status is a word, not a colour** — `pending`/`running`/`success`/`error` each carry their own label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had it — and the four map one for one onto what every runtime 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**: `AgentUtils.formatValue` (framework-free, same entry) caps the text at `valueLimit` — 20,000 characters, with a line saying how much was left — and answers a circular structure, a `BigInt` and a function, all three of which make `JSON.stringify` throw or drop them silently. **Four things to know.** `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`; the answer lands in a `role="status"` **that is in the DOM before there is anything in it**, the toaster's rule, and `busy` is the round trip to a server. The card **does not take focus** unless `autoFocus` says so, and then on _Reject_ — APG's least-destructive rule — because a turn arrives while the reader is somewhere else. A `ToolCallCard` with nothing to disclose **renders no control at all**, the header being a row of text rather than a button nobody wants to land on. And `Reasoning` is **closed by default** and opens in the same `1fr`-to-`0fr` grid an `Accordion` panel does, so nothing is measured: `streaming` only changes the words and the shimmer, and `open={streaming}` is how an app makes it follow. Styling is the `toolCall`/`approval`/`reasoning` trees, where the status is a variant on `toolCall.status`, the decision one on `approval` itself, and `toolCall.header`'s `interactive` variant is what makes a header look pressable.
116
+
117
+ 44. **What it _says_ is `<StreamingText>`, `markdownComponents` and `<Skeleton>`.** `<StreamingText text={message} streaming />` (`components/agent`) takes the text so far and fades in the part that was not there a render ago — **hand it the whole message every time, not the delta**, because knowing what is new means comparing the two. What is on the page is one settled string plus the last few runs to arrive (`window`, 8 by default; `0` turns the entrance off), so a message already whole paints at once with nothing moving — a prerendered page, a transcript read back — and one still arriving costs the same at the ten-thousandth token as at the first. The entrance is `@starting-style` rather than a keyframe, so it rides `--transitionTime` and stops under `prefers-reduced-motion` on its own, and the element is deliberately **not** a live region: one announcing every token would read the message out a word at a time and again when it finished, so it carries `aria-busy` and the transcript does the announcing. **The markdown bridge is a map, not a component**: `markdownComponents` (`components/markdown`) is the `components` map `react-markdown`, Streamdown and everything on that shape already take, so the parser stays the app's and there is no stylesheet, no Tailwind `@source` line and no design tokens to declare — wrap the renderer in `<Box component="markdown">` and the nodes inherit its size and colour. It is a **constant**, and that matters while streaming: a map built inside render is a new set of component _types_ every token, which React answers by unmounting the whole message, so override by spreading at module scope (`{ ...markdownComponents, h1: MyHeading }`). Whether a URL is safe stays the renderer's `urlTransform`, since the href is parsed before a component is called. **`<Skeleton lines={3} />`** (`components/skeleton`) is the placeholder — `circle` for an avatar, the gloss a named duration so it stops itself — and it is `aria-hidden` unless given a `label`, which makes it one `role="status"`: put that on the skeleton standing for a region, never on every bar. Styling is `streamingText` (`segment`, `caret`), `skeleton` (`bar` with `short`/`circle`, `gloss`) and `markdown` (`heading`, `paragraph`, `link`, `list`, `item`, `quote`, `code`, `codeBlock`, `rule`, `image`, `table`, `row`, `cell`, `inline`, `checkbox`).
118
+
119
+ 45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.
120
+
102
121
  Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.