@box-kite/react 2.0.1 → 2.2.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.
- package/.claude/rules/box-kite-rules.md +24 -2
- package/.claude/skills/box-kite/SKILL.md +27 -5
- package/.claude/skills/box-kite/references/components.md +8 -3
- package/.claude/skills/box-kite/references/patterns.md +24 -1
- package/.claude/skills/box-kite/references/styling.md +23 -0
- package/.cursor/rules/box-kite.mdc +25 -3
- package/AGENTS.md +28 -3
- package/BOX_AI_CONTEXT.md +732 -12
- package/BOX_KITE_AI_CONTEXT.md +732 -12
- package/README.md +41 -9
- package/box.cjs +1 -1
- package/box.d.ts +13 -2
- package/box.mjs +25 -18
- package/catalog.cjs +1 -0
- package/catalog.d.ts +8 -0
- package/catalog.mjs +2347 -0
- package/client.cjs +1 -1
- package/client.mjs +59 -53
- package/components/agent.cjs +1 -0
- package/components/agent.d.ts +215 -0
- package/components/agent.mjs +405 -0
- package/components/button.cjs +1 -1
- package/components/button.mjs +10 -14
- package/components/chart.d.ts +1 -0
- package/components/dashboard.cjs +1 -0
- package/components/dashboard.d.ts +121 -0
- package/components/dashboard.mjs +690 -0
- package/components/dataGrid/components/dataGridBody.d.ts +2 -1
- package/components/dataGrid/components/dataGridDetailRow.d.ts +13 -2
- package/components/dataGrid/components/dataGridGroupRow.d.ts +13 -2
- package/components/dataGrid/components/dataGridRow.d.ts +13 -2
- package/components/dataGrid/contracts/dataGridContract.d.ts +66 -2
- package/components/dataGrid/models/cellModel.d.ts +3 -0
- package/components/dataGrid/models/editModel.d.ts +27 -0
- package/components/dataGrid/models/exportModel.d.ts +2 -1
- package/components/dataGrid/models/gridModel.d.ts +3 -0
- package/components/dataGrid/models/rangeModel.d.ts +119 -0
- package/components/dataGrid/models/viewportModel.d.ts +23 -2
- package/components/dataGrid/useGridNavigation.d.ts +10 -2
- package/components/dataGrid.cjs +4 -1
- package/components/dataGrid.d.ts +3 -0
- package/components/dataGrid.mjs +1056 -765
- package/components/flex.cjs +1 -1
- package/components/flex.mjs +10 -13
- package/components/form.cjs +1 -1
- package/components/form.mjs +7 -7
- package/components/grid.cjs +1 -1
- package/components/grid.mjs +10 -13
- package/components/markdown.cjs +1 -0
- package/components/markdown.d.ts +55 -0
- package/components/markdown.mjs +179 -0
- package/components/menu.cjs +1 -1
- package/components/menu.mjs +16 -16
- package/components/overlay.cjs +1 -1
- package/components/overlay.mjs +35 -34
- package/components/popover.cjs +1 -1
- package/components/popover.mjs +4 -4
- package/components/semantics.cjs +1 -1
- package/components/semantics.d.ts +29 -29
- package/components/semantics.mjs +9 -13
- package/components/skeleton.cjs +1 -0
- package/components/skeleton.d.ts +34 -0
- package/components/skeleton.mjs +25 -0
- package/components/svg.cjs +1 -1
- package/components/svg.mjs +38 -44
- package/components/textarea.cjs +1 -1
- package/components/textarea.mjs +12 -16
- package/components/textbox.cjs +1 -1
- package/components/textbox.mjs +12 -16
- package/components/toaster.cjs +1 -1
- package/components/toaster.mjs +42 -43
- package/components/visuallyHidden.cjs +1 -1
- package/components/visuallyHidden.mjs +10 -13
- package/dashboardSchema.cjs +1 -0
- package/dashboardSchema.mjs +78 -0
- package/docs/a11y.md +2 -3
- package/docs/catalog.md +451 -0
- package/docs/components.md +4 -0
- package/docs/index.md +3 -0
- package/docs/interop.md +237 -0
- package/docs/mcp.md +70 -0
- package/docs/props.md +135 -23
- package/interop.cjs +1 -0
- package/interop.d.ts +51 -0
- package/interop.mjs +454 -0
- package/layer.cjs +1 -1
- package/layer.mjs +61 -59
- package/mcp/src/data.d.ts +88 -0
- package/mcp/src/data.test.d.ts +1 -0
- package/mcp/src/engine.d.ts +23 -0
- package/mcp/src/index.d.ts +1 -0
- package/mcp/src/search.d.ts +26 -0
- package/mcp/src/search.test.d.ts +1 -0
- package/mcp/src/server.d.ts +18 -0
- package/mcp/src/server.test.d.ts +1 -0
- package/mcp/src/styles.d.ts +27 -0
- package/mcp/src/styles.test.d.ts +1 -0
- package/mcp/src/tools.d.ts +18 -0
- package/mcp/src/vocabulary.d.ts +41 -0
- package/package.json +170 -37
- package/react/boxComponent.d.ts +10 -0
- package/react/popover/useTopLayer.d.ts +17 -0
- package/react/spec/renderSpec.d.ts +51 -0
- package/react/spec/specBoundary.d.ts +25 -0
- package/react/spec/specRegistry.d.ts +42 -0
- package/react/spec/specRenderer.d.ts +10 -0
- package/react/theme/theme.d.ts +10 -0
- package/react/useStyles.d.ts +2 -0
- package/react-shared.cjs +1 -1
- package/react-shared.mjs +44 -37
- package/registry/blocks/dashboard-shell/dashboard-shell.d.ts +22 -0
- package/registry/blocks/dashboard-shell/dashboard-stats.d.ts +13 -0
- package/registry/blocks/data-grid/invoice-rows.d.ts +15 -0
- package/registry/blocks/data-grid/invoices-grid.d.ts +1 -0
- package/registry/blocks/settings-form/settings-form.d.ts +17 -0
- package/rsc.cjs +1 -1
- package/rsc.d.ts +11 -1
- package/rsc.mjs +15 -12
- package/spec.cjs +2 -0
- package/spec.d.ts +12 -0
- package/spec.mjs +382 -0
- package/specData.cjs +1 -0
- package/specData.mjs +69 -0
- package/specDefs.cjs +1 -0
- package/specDefs.mjs +33 -0
- package/specSchema.cjs +1 -0
- package/specSchema.mjs +88 -0
- package/ssg.cjs +1 -1
- package/ssg.d.ts +8 -2
- package/ssg.mjs +13 -9
- package/utils/agent/agentUtils.d.ts +81 -0
- package/utils/catalog/catalogContracts.d.ts +19 -0
- package/utils/catalog/catalogManifest.d.ts +8 -0
- package/utils/dashboard/dashboardSchema.d.ts +18 -0
- package/utils/dashboard/dashboardUtils.d.ts +97 -0
- package/utils/environment/environmentUtils.d.ts +5 -0
- package/utils/interop/a2uiInterop.d.ts +82 -0
- package/utils/interop/assistantUiInterop.d.ts +34 -0
- package/utils/interop/toolInterop.d.ts +30 -0
- package/utils/spec/specData.d.ts +19 -0
- package/utils/spec/specDefs.d.ts +11 -0
- package/utils/spec/specProps.d.ts +15 -0
- package/utils/spec/specSchema.d.ts +23 -0
- package/utils/spec/specTypes.d.ts +71 -0
- package/utils/spec/specValidate.d.ts +10 -0
package/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @box-kite/react — read this before writing any of it
|
|
2
2
|
|
|
3
|
-
**This library is not in your training data.** It was renamed at 1.0.0 (September 2026) and its prop surface nearly doubled on the way, so a plausible-looking memory of it is a memory of a different library. Its prop names collide with Tailwind's and Chakra's while the numbers mean different things, which is the failure mode to expect: code that compiles and is laid out wrong. Read the rules below, then `docs/props.md` for the
|
|
3
|
+
**This library is not in your training data.** It was renamed at 1.0.0 (September 2026) and its prop surface nearly doubled on the way, so a plausible-looking memory of it is a memory of a different library. Its prop names collide with Tailwind's and Chakra's while the numbers mean different things, which is the failure mode to expect: code that compiles and is laid out wrong. Read the rules below, then `docs/props.md` for the 235 props themselves.
|
|
4
4
|
|
|
5
5
|
## Where the files are
|
|
6
6
|
|
|
@@ -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
|
|
@@ -56,14 +59,14 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
56
59
|
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
|
|
57
60
|
13. **An animation is a prop**: `animation="spin|pulse|bounce|ping"` (keyframes included, and they stop under reduced motion on their own), `Box.keyframes({ name: { from, to } })` for your own — its steps are Box props. **Times are milliseconds**: `animationDuration={1100}`, `transitionDelay={150}`. `transition` takes a group (`colors`, `transform`, `opacity`, `shadow`, `size`, `filter`), and `translateX`/`translateY`/`rotate`/`scale` are longhands that compose. **A spring is two props**: `transitionTimingFunction="spring-bouncy"` with `transitionDuration="spring-bouncy"` (also `spring`, `spring-gentle`, `spring-snappy`; `Box.spring({ stiffness, damping })` for your own)
|
|
58
61
|
14. **An entrance is a prop too**: `startingStyle={{ opacity: 0, translateY: 2 }}` is what a just-mounted element starts from (`@starting-style`, plain props only — nest a breakpoint or a theme _around_ it). Going out, React unmounts too fast to animate: either hide instead with `display` + `transitionBehavior="allow-discrete"`, which flips `display` at the _end_, or hold the node with **`<Presence present>`** (`components/presence`) — a render prop given `{ present, ref, props }`, where `ref` goes on the element carrying the transition. `interpolateSize="allow-keywords"` on a container is what makes `height: auto` animate (Chromium-only, snaps elsewhere)
|
|
59
|
-
15. **`data-*` and `aria-*` are attributes, so they go in `props`** — `props={{ 'data-state': 'open' }}`. Written at the top level (`<Box data-state="open">`) they typecheck and are
|
|
62
|
+
15. **`data-*` and `aria-*` are attributes, so they go in `props`** — `props={{ 'data-state': 'open' }}`. Written at the top level (`<Box data-state="open">`) they typecheck and are dropped — with a console warning in development (rule 48), with nothing at all in production
|
|
60
63
|
16. **A state your own code sets is a nested prop too**: `dataAttr={{ 'state=open': { … } }}` → `[data-state="open"]` (a bare key is presence: `[data-loading]`), `ariaAttr={{ selected: { … } }}` → `[aria-selected="true"]`, `has={{ ':checked': { … } }}`, `not={{ hover: { … } }}` — keyed by state name, so it stays typed — and `nth={{ odd: { … }, 'last 1': { … } }}` → `:nth-child(odd)`/`:nth-last-child(1)`, which also takes `first`/`last`/`only`/`even` and an `An+B` formula. The attribute itself still goes in `props`; everything else (a breakpoint, a theme, a group, `startingStyle`) nests around them in either direction. A key the grammar rejects drops its whole block, the way an unmatched value does
|
|
61
64
|
17. **A pseudo-element is a nested prop, and CSS allows one**: `before`/`after` (which come with `content: ''`, because a generated element with none renders nothing), `placeholder`, `selection`, `marker`, `firstLine`, `firstLetter`, `backdrop`, `fileButton`. It is a slot, not a list — a second nested one is a type error — and it lands last on the element's own selector, so it composes with everything else in either direction. **`content` takes text and quotes it for you** (`content="New"` → `content: "New"`); `attr()`/`counter()`/`url()`/`var()` and a quoted sequence (`'"Step " counter(step)'`) are written out as CSS. `marker` and `selection` reach descendants, so they go on the `<Ul>` or the `<P>`. On a `Textbox`/`Textarea` a `placeholder` **string** is the attribute and an object is the styles; for both, the text goes in `props`
|
|
62
65
|
18. **A forced-colors mode keeps only the system colours**, so they are values on every colour prop: `forcedColors={{ bgColor: 'ButtonFace', color: 'ButtonText' }}` (also `Canvas`, `CanvasText`, `Highlight`, `HighlightText`, `GrayText`, `LinkText`). They are keywords, not tokens — a state signalled by fill alone needs them or it reads identically on and off
|
|
63
66
|
|
|
64
67
|
19. **A component that answers to its own space uses `cq`, not a breakpoint**: `container` makes an element a query container (`true`, or a name — both mean `container-type: inline-size`; `containerName`/`containerType` are the longhands), and `cq={{ md: { d: 'row' } }}` → `@container (min-width: 28rem)`. Six sizes, `xs` 20rem through `xxl` 42rem — **the container scale, not the breakpoint one** — each with a complement (`maxMd` is `not (min-width: 28rem)`), and `cq={{ 'sidebar/md': { … } }}` addresses a container by name. Ranked after every breakpoint and before every preference; one at-rule block per rule, so `cq` and a breakpoint do not nest inside each other
|
|
65
68
|
|
|
66
|
-
20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too
|
|
69
|
+
20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. **A token carries its own value as the fallback** — `bgColor="blue-500"` is `var(--blue-500,oklch(62.3% .214 259.8))`, and `var(--blue-500)` in these rules is that, shortened — so nothing is declared in `:root`: your own stylesheet reads a token through a variable of yours (`vars={{ brand: 'violet-500' }}` on an ancestor), while a hand-written `var(--violet-500)` inside a Box value (`fill`, `vars`, `css`, a `Box.extend()` variable) gets the fallback too. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too
|
|
67
70
|
|
|
68
71
|
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
69
72
|
|
|
@@ -99,4 +102,26 @@ 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
|
+
|
|
121
|
+
46. **A scroll-driven animation is a timeline prop, and it costs no JavaScript at all.** `animationTimeline="scroll()"` runs an animation off the nearest scrollport's progress and `"view()"` off this element's own pass across it — no listener, no rAF, no state, and the browser drives it on the compositor. Where the scroller is not an ancestor, name one: `scrollTimeline="page block"` on the scroller (or `viewTimeline="card block"` on the subject), `animationTimeline="page"` on the animated element, and `timelineScope="page"` on their common ancestor when the two are not nested — the `--` on a name is optional and added for you, the axis is `block`/`inline`/`x`/`y` (logical, so it mirrors), and the longhands `scrollTimelineName`/`Axis` and `viewTimelineName`/`Axis` exist to override half a shorthand. **`animationRange` is which part of the pass the animation occupies**: `cover`, `contain`, `entry`, `exit`, `entry-crossing`, `exit-crossing`, each taking an offset — `animationRange="entry 0% entry 50%"`, or the `animationRangeStart`/`animationRangeEnd` pair — and `viewTimelineInset` shrinks the scrollport the pass is measured against, which is how a reveal fires before the element really reaches the edge. **Three traps.** The CSS `animation` shorthand **resets `animation-timeline`**, so a timeline declared before it is silently undone; the registry's order settles that for the props, but not for an `animation` written inside `css`. A scroll-driven animation has **no duration**, so `--transitionTime` cannot zero it and it is the one motion here that does _not_ stop itself under `prefers-reduced-motion` — `motionReduce={{ animation: 'none' }}` is the opt-in, and it is not optional. And where the browser has none (Safari, and Firefox behind a flag) the declaration is dropped rather than the animation: it runs on the **document timeline** over whatever duration it was given, so make the end state the resting state and a missing timeline degrades to "already arrived" instead of a loop.
|
|
122
|
+
|
|
123
|
+
47. **A view transition is two props and one call.** `Box.viewTransition(() => applyChange())` runs a DOM change inside `document.startViewTransition` where the browser has one and plainly where it has not — the same `ready`/`finished`/`updateCallbackDone` promises and `skip()` either way, so a caller has one code path rather than two — and `viewTransitionName="header"` is what makes an element animate _from where it was to where it is_ instead of being cross-faded along with the page. `viewTransitionClass="card"` groups several names, so `::view-transition-group(.card)` styles them at once. **In React the update has to be flushed**: the browser screenshots the page the moment the callback returns and a `setState` has not rendered by then, so a hand-rolled version captures the old state twice and nothing appears to move — write `Box.viewTransition(() => flushSync(() => setTheme('dark')))`, or use **`<Box.Theme viewTransition>`**, which is that call for the one change every app has. **A name has to be unique in the document while the transition runs**, so a list wants a name per item — and a class per item is a rule this library would never free, which is why that one goes in `props={{ style: { viewTransitionName: id } }}`, the exception `useAnchorPosition`'s anchor name and a `Slider` thumb already take. Reduced motion **skips** the transition by default and keeps the update, a whole-page cross-fade being exactly the motion the preference is about; `{ reducedMotion: 'play' }` is the opt-out, and `{ types: ['forward'] }` is what `:active-view-transition-type()` selects on.
|
|
124
|
+
|
|
125
|
+
48. **In development the console is part of the answer: read it.** Three mistakes write nothing, or the wrong thing, and say so in one `[box-kite]` warning that names what rendered and the line to write instead. **A value a prop does not take** — `bgColor="bleu-500"` → `wrote no CSS … Did you mean "blue-500"?`, `opacity={50}` → `It takes one of 0, 0.1, …`. **An HTML attribute at the top level** — `href`, `onClick`, `data-*`, `aria-*` → `write props={{ href: ... }}`. **A number in a unit it almost certainly did not mean**, which is valid CSS and so the one mistake nothing else catches: `fontSize={4}` is 4px text (`For 16px write fontSize={16}`), `lineHeight={1.5}` a 1.5px line, `transitionDuration={0.3}` 0.3ms, `brightness={1.1}` 1.1%. Each prints once per value, in any environment whose `NODE_ENV` is not `production` — a test run included, which is the cheapest place to see them — and never in a production build, where a rejected value is still dropped with nothing said. `Box.configure({ warnings: false })` turns them off; `check_styles` in `@box-kite/mcp` gives the same three verdicts before anything renders.
|
|
126
|
+
|
|
102
127
|
Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.
|