@box-kite/react 2.3.1 → 2.4.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 +58 -94
- package/.claude/skills/box-kite/SKILL.md +69 -104
- package/.claude/skills/box-kite/references/components.md +1 -1
- package/.claude/skills/box-kite/references/patterns.md +1 -1
- package/.claude/skills/box-kite/references/rules.md +115 -0
- package/.cursor/rules/box-kite.mdc +60 -96
- package/AGENTS.md +61 -97
- package/BOX_AI_CONTEXT.md +39 -38
- package/BOX_KITE_AI_CONTEXT.md +39 -38
- package/README.md +200 -721
- package/client.cjs +1 -1
- package/client.mjs +81 -81
- package/components/combobox/comboboxListbox.d.ts +2 -1
- package/components/combobox.cjs +1 -1
- package/components/combobox.d.ts +1 -1
- package/components/combobox.mjs +199 -188
- package/components/dataGrid/contracts/dataGridContract.d.ts +2 -1
- package/components/dataGrid/useListLayout.d.ts +1 -1
- package/components/dataGrid.cjs +3 -3
- package/components/dataGrid.mjs +453 -453
- package/components/radioGroup.cjs +1 -1
- package/components/radioGroup.mjs +1 -0
- package/components/semantics.d.ts +29 -29
- package/components/skeleton.cjs +1 -1
- package/components/skeleton.d.ts +1 -1
- package/components/skeleton.mjs +2 -1
- package/components/textarea.d.ts +1 -1
- package/components/textbox.d.ts +1 -1
- package/components/timeZoneSelect.d.ts +1 -1
- package/components/toaster.cjs +1 -1
- package/components/toaster.mjs +13 -14
- package/docs/a11y.md +1 -2
- package/docs/index.md +1 -1
- package/effects.cjs +1 -1
- package/effects.mjs +2 -2
- package/identity.cjs +1 -1
- package/identity.mjs +5 -7
- package/package.json +9 -9
- package/react/effects.d.ts +5 -6
- package/react/identity/useIdentifier.d.ts +0 -3
|
@@ -13,99 +13,63 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
13
13
|
- **The element comes from the component, not from `tag`.** `<Flex>`, `<Button>`, `<H1>`, `<Ul>`, `<Path>` — `<Box tag="…">` is the fallback, not the idiom.
|
|
14
14
|
- **HTML attributes go in `props`.** `<Link props={{ href: '/about' }}>`; an `href` written at the top level typechecks and is then dropped.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
20. **An opacity is part of the colour
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
89
|
-
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.
|
|
90
|
-
|
|
91
|
-
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 — `viewTransition="reveal"` grows the new theme out of the control that was pressed as a circle instead of cross-fading. **A theme change pauses every CSS transition** until it has painted (and until the view transition has finished), because `color` is inherited and the base class transitions `all`, so each element would otherwise start one of its own; the view transition is the animation, and it costs the same on any size of page. **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.
|
|
92
|
-
|
|
93
|
-
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.
|
|
94
|
-
|
|
95
|
-
49. **A design that stops at a subtree is `Box.createComponents()`, not `Box.components()`.** `Box.components()` is one registry for the whole app: a second call merges into the first, so two designs cannot share a page through it. `const aurora = Box.createComponents({ button: { styles: { borderRadius: 999 } } })` takes the same shape and registers nothing, and `<Box.Components value={aurora}>` — or `<Box.Theme use="local" components={aurora}>`, a design in light and dark in one wrapper — applies it to every Box inside, pre-built components included. `base` is what it merges over: `'global'` (default, the app's own `Box.components()`), `'builtin'` (ignores them) or another registry. Props still beat the design. It is identified by its content, so an inline one costs no cache, but declare it at module scope. The provider renders no element and a Server Component cannot read it. Two designs that both name `button` type as `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}`.
|
|
96
|
-
|
|
97
|
-
50. **A list of things to read and pick from is `<List>`, and one action covers each item.** `<List><List.Item chevron><List.Media variant="avatar">AL</List.Media><List.Content><List.Row><List.Title lines={1}><List.Action props={{ href: '/inbox/42' }}>Ada Lovelace</List.Action></List.Title><List.Meta>9:41</List.Meta></List.Row><List.Description lines={2}>…</List.Description></List.Content></List.Item></List>` (`components/list`). **An item is a flex row of three slots** — `List.Media` leading (`variant` `icon`/`avatar`/`image`), `List.Content` taking the room, `List.End` trailing (a value over its label, aligned to the end) — and the content is a column of `List.Row`s, each its parts side by side with the last pushed to the end: a main row (the title and its time) and a `secondary` one under it (smaller, muted). `wrap` on a row or an item lets the parts wrap instead of squeezing; `List.Overline`/`List.Title`/`List.Description` take `lines={1|2|3}` (an ellipsis, then a clamp); `List.Meta` is a small value that never wraps; `List.Fields` + `List.Field label` is a `<dl>` of labelled values; `List.Group label level sticky` is a section with a nested list of its own. **`List.Action` goes in the title and its `::after` covers the item**, so the whole row presses while the name a reader hears is the title alone — an `href` in `props` makes it an `<a>`, an `htmlFor` a `<label>` (a press anywhere ticks that checkbox), neither a `<button>` with a top-level `onClick`; a control of the item's own (a switch, a menu, that checkbox) goes in `List.Actions`, which sits above it. **Four things to know.** It renders on a server — no hook, no context — so the list's look reaches its items as **custom properties**: `variant="plain"|"card"|"separated"` on `List`, and `vars={{ 'list-divider': …, 'list-divider-inset': '4.5rem', 'list-item-radius': …, 'list-sticky-top': '4rem' }}` to tune it. The style tree is **paint only** (`list`, `list.item`, `list.media`, `list.muted`, `list.action`, `list.label`), the hairlines and tints mixed from `currentColor`; the spacing is each part's default props, so `<List.Item py={1}>` wins and `Box.components` cannot move it. Hover, pressed, `aria-current` and disabled are read off the action with `:has()` — no prop — and `selected` on an item is a tint only, the state a reader hears belonging on the control. And it is a `<ul role="list">` (Safari drops the semantics otherwise; `tag="ol"` for an ordered one), **not a composite widget**: every action is a tab stop and the arrows stay the page's — arrow-key selection among options is a listbox, `Combobox` or `Select`.
|
|
98
|
-
|
|
99
|
-
51. **On a phone a DataGrid is a list, and a column says where it goes in an item.** Below `def.list.breakpoint` (576px) of **the grid's own width** — not the viewport's — `layout: 'auto'` (the default) draws each row as a list item: `list: 'title'` is its name and the button that opens it, `'kicker'` a small line above, `'subtitle'` a line under, `'end'` the one value opposite the title with its column's name under it, `'field'` a labelled value, `'detail'` the record view only, `'hidden'` nowhere. Left unset, the first column is the title, the first end-aligned one the end, the next four fields (`def.list.fields`) and the rest detail. A press opens the **record view** — every column of one row in a bottom sheet with a pager (buttons, Left/Right, a swipe) that follows the list's sort and filters, and an **Edit** button on each editable column through the same `onCellEdit`. Sort and filters are bottom sheets behind two pills, the filters in force are removable chips, **Select** is a mode (a press ticks instead of opening, and a bar pinned to the bottom counts the selection), and the list grows twenty a **Show more** — a button, so the totals and the page below stay reachable — or pages when the host paginates. **Four things to know.** It is a `<List>` (rule 50) — each row a `List.Item` whose title is the `List.Action` — not a restyled `role="grid"`, so the `list` tree styles its rows. It is a chunk of its own, loaded the first time a grid is narrow, and the grid draws its table until it arrives — in a test, `await loadListLayout()` first. Unmeasured is a table, so a server prerenders the table and `layout: 'list'` is how to say otherwise. And the selection bar sticks to the viewport, so no ancestor may be `overflow: hidden`/`auto` — `css={{ overflow: 'clip' }}` clips without being a scroll container. `layout: 'table'` keeps the table at every width.
|
|
100
|
-
|
|
101
|
-
52. **Swiping, reordering and windowing a list are three client entries on top of `List`.** `<SwipeItem endActions={<SwipeItem.Action variant="danger" onClick={remove}>Delete</SwipeItem.Action>} fullSwipe>` (`components/swipeItem`) is a `List.Item` — same parts, `size`, `chevron`, `selected` — whose row slides to open a tray; the first action of each tray is the outermost, and `fullSwipe` performs it by clicking it, so its `onClick` is where the command lives. The actions are tab stops and focus opens their tray, but a swipe is a path gesture: put the same commands where a press reaches too. `<SortableList onOrderChange={(order) => setIds(order)}>` (`components/sortableList`) with `<SortableList.Item value={id} label={name}>` and a `<SortableList.Handle />` in each: **the app owns the order** and renders it, `onOrderChange` fires once per drop and per keyboard step, `onOrderCommit` when it settles; the keyboard grabs (Enter/Space, arrows, Escape puts back), and a press on a handle then a press on another item drops it there. `<VirtualList items={rows} itemKey={(row) => row.id}>{(row) => <List.Item>…</List.Item>}</VirtualList>` (`components/virtualList`) renders a window, measuring each item by key; the scroller is the nearest scrolling ancestor or the page, `scrollerRef` when that is wrong, and `onRangeChange` fetches the next page. **Three things to know.** The children function returns exactly one `<li>` per item and nothing around it, since the list measures its children. A windowed list has no `List.Group`s and find-in-page cannot see what is not rendered. And a `SwipeItem` windows like any item, but a `SortableList` holds its whole list: reorder a list short enough to render, window one too long to.
|
|
102
|
-
|
|
103
|
-
53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime` holding only those within `min`/`max`, `presets`, `matchWidth`, and `pickOnly` for a field that is one button with nothing to type), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
|
|
104
|
-
|
|
105
|
-
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.
|
|
106
|
-
|
|
107
|
-
54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
|
|
108
|
-
|
|
109
|
-
55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
|
|
110
|
-
|
|
111
|
-
56. **An SVG shape turns about the viewBox's corner, not its own centre.** Its `transform-box` is `view-box`, so `rotate={45}` alone swings it round `0 0`: write `<G rotate={45} transformBox="fill-box" transformOrigin="center">`. `transformOrigin` takes a keyword (`'top left'`), a fraction or a percentage, a ÷4 number, or positions as CSS writes them (`'48px 48px'` — a px is a user unit). The `transform` attribute stays for a matrix or an angle that is data. Two more props an interactive drawing needs: `pointerEvents="visibleStroke"` makes an edge's stroke its hit area (over a wide transparent one), and `touchAction="none"` on the pressed element keeps a finger's drag from panning the page.
|
|
16
|
+
Each rule is what to write and the trap to avoid. The reasons, measurements and edge cases behind rule N are under the same number in the skill's `references/rules.md`.
|
|
17
|
+
|
|
18
|
+
1. **NEVER use `style={{ }}`** — every visual value is a Box prop. A missing one is `Box.extend()`; a one-off is `css={{ }}` (rule 26).
|
|
19
|
+
2. **NEVER `<Box tag="...">`** for common elements — use `<Button>`, `<Link>`, `<H1>`, `<P>`, `<Ul>`/`<Li>`, `<Nav>`, `<Flex>`, `<Grid>`, and for SVG `<Svg>`, `<Path>`, `<Circle>`, `<Rect>`, `<SvgText>`… from `components/svg`. A component is its entry's **default** export (`import Button from '@box-kite/react/components/button'`); families are named (`{ H1, P }` from `semantics`, `{ Path }` from `svg`, `{ Sparkline }` from `chart`, `{ AlertDialog }` from `dialog`, `{ toast }` from `toaster`).
|
|
20
|
+
3. **An icon from lucide/Tabler/react-icons goes in `<Icon>`** (`components/icon`): `<Icon size={5} color="amber-500"><Sun /></Icon>`. `size` is the ÷4 scale; no `label` means `aria-hidden`. SVG you draw yourself is `<Svg>`. Other sets come through the same `<Icon>` (`unplugin-icons`, `@iconify/react`).
|
|
21
|
+
4. **NEVER `<Box display="flex/grid">`** — use `<Flex>` / `<Grid>`. `d` is `flex-direction` (`d="column"`), not `display`.
|
|
22
|
+
5. **fontSize divider is 16** (not 4): `fontSize={14}` → 14px.
|
|
23
|
+
6. **Spacing divider is 4**: `p={4}` → 16px (1rem).
|
|
24
|
+
7. **Border width and lineHeight are direct px**: `b={1}` → 1px, `lineHeight={24}` → 24px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px.
|
|
25
|
+
8. **A paint reference is a value, not an attribute**: `fill="url(#sky)"`, `stroke="var(--chart-1)"`, `clipPath="url(#frame)"`. A dashboard shape is `components/chart`: `<Sparkline>`, `<ProgressRing>`, `<Gauge>`, `<MiniDonut>`.
|
|
26
|
+
9. **A CSS variable is a prop**: `vars={{ 'color-revenue': 'sky-500' }}` declares `--color-revenue` for the subtree. A third-party chart goes in `<ChartContainer series={['revenue', 'cost']}>` and reads `var(--color-revenue)`; `series` also takes a record of paints.
|
|
27
|
+
10. **SVG lengths have no divider**: `strokeWidth={2}` → `stroke-width: 2`; `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y` write px (user units). On `<Rect>`, `width`/`height` are the SVG attributes; on `<Path>`, `d` is path data.
|
|
28
|
+
11. **HTML attributes go in `props`**: `<Link props={{ href: '/about' }}>`, not `<Link href>` — at the top level they typecheck and are dropped. The form controls take their own directly: `<Button type="submit" onClick={…}>`, `<Textbox name="email" type="email" value onChange>`, `<Checkbox name defaultChecked onChange>`.
|
|
29
|
+
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%.
|
|
30
|
+
13. **An animation is a prop**: `animation="spin|pulse|bounce|ping"`, or your own with `Box.keyframes()`. Times are ms (`transitionDuration={150}`). `transition` takes a group (`colors`, `transform`, `opacity`, `shadow`, `size`, `filter`). `translateX`/`translateY`/`rotate`/`scale` compose. A spring names both props: `transitionTimingFunction="spring-bouncy"` + `transitionDuration="spring-bouncy"`, or `Box.spring()`.
|
|
31
|
+
14. **An entrance is `startingStyle`**: `startingStyle={{ opacity: 0, translateY: 2 }}` (plain props; nest a breakpoint or theme around it). For an exit, hide with `display` + `transitionBehavior="allow-discrete"`, or hold the node with `<Presence present>` (`components/presence`), putting its `ref` on the element that transitions. `interpolateSize="allow-keywords"` animates `height: auto`.
|
|
32
|
+
15. **`data-*` and `aria-*` go in `props`**: `props={{ 'data-state': 'open' }}`. At the top level they are dropped.
|
|
33
|
+
16. **Style a state your code sets with nested props**: `dataAttr={{ 'state=open': { … } }}`, `ariaAttr={{ selected: { … } }}`, `has={{ ':checked': { … } }}`, `not={{ hover: { … } }}`, `nth={{ odd: { … } }}`. The attribute itself still goes in `props`; a key the grammar rejects drops its block.
|
|
34
|
+
17. **A pseudo-element is a nested prop, one per rule**: `before`/`after` (with `content: ''` added), `placeholder`, `selection`, `marker`, `firstLine`, `firstLetter`, `backdrop`, `fileButton`. `content="New"` is quoted for you. Put `marker`/`selection` on the `<Ul>`/`<P>`. On `Textbox`/`Textarea` an object `placeholder` is styles; the text goes in `props`.
|
|
35
|
+
18. **Forced colors keep only system colours**: `forcedColors={{ bgColor: 'ButtonFace', color: 'ButtonText' }}` (also `Canvas`, `CanvasText`, `Highlight`, `HighlightText`, `GrayText`, `LinkText`). A state shown by fill alone needs them.
|
|
36
|
+
19. **A component sized by its own space uses `cq`, not a breakpoint**: `container` on the parent, `cq={{ md: { d: 'row' } }}` → `@container (min-width: 28rem)`. Sizes `xs` (20rem) to `xxl` (42rem), `maxMd` is the complement, `'sidebar/md'` names a container. `cq` and a breakpoint do not nest.
|
|
37
|
+
20. **An opacity is part of the colour**: `bgColor="blue-500/40"` on every colour prop and in `vars` — not `opacity`, which fades the element and its children. The palette is Tailwind 4.3's OKLCH families, `50`–`950`; an unknown token or a percentage over 100 writes nothing.
|
|
38
|
+
21. **A gradient is a record, and shadows stack**: `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` (or `radial`, `conic`; `interpolate="oklch"`); one bad stop or key writes nothing. `shadow` (`xxs`…`xxl`), `insetShadow`, `ring` and `insetRing` (px) are four layers that coexist, each with its own colour prop. `textShadow` + `textShadowColor` for text.
|
|
39
|
+
22. **Filters stack, and a mask is a gradient**: `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia`, `dropShadow` — each number is that function's unit (`brightness={110}` is %, `hueRotate={90}` degrees, `blur={3}` px or `xs`…`xxxl`). `backdrop*` are the same behind the element. `maskImage` takes the `bgGradient` record. Gradient text is `bgClip="text"` + `color="transparent"`.
|
|
40
|
+
23. **Alignment can be overflow-safe, and a ratio is a value**: every alignment prop takes `'safe center'`. `aspectRatio` is `'square'`, `'video'`, `'4/3'` or a number (`'4:3'` writes nothing). `insetX`/`insetY` and every inset take `'auto'` and fractions. Native controls: `accentColor`, `caretColor`, `colorScheme`, `fieldSizing="content"`, `scrollbarGutter="stable"`. `willChange` only on the few elements that move.
|
|
41
|
+
24. **Another element's state is `group` or `peer`**: `group={{ 'card/hover': { … } }}` → `.card:hover .x`; `peer={{ 'agree/checked': { … } }}` → `.agree:checked ~ .x`; the ancestor or sibling carries the name in `className`. Also state props: `open`, `placeholderShown`, `autofill`, `inRange`/`outOfRange`, `target`, `inert`, `visited` (colours only), and media keys `pointerCoarse`/`pointerFine`.
|
|
42
|
+
25. **Sides are logical, and direction is a state**: `ps`/`pe`, `ms`/`me`, `bs`/`be`, `insetStart`/`insetEnd`, `borderRadiusStart`/`borderRadiusEnd` and the logical corners (block axis first); `textAlign="start"`. For what cannot be logical, `rtl={{ flip: 'xAxis' }}` → `:dir(rtl)`. The `dir` attribute goes in `props`.
|
|
43
|
+
26. **A one-off property is `css`, and it is still a class**: `css={{ mixBlendMode: 'multiply', WebkitLineClamp: 2 }}` — camelCase, typed, lengths with units (`width: '100px'`), colour tokens resolve. It nests anywhere a prop does and wins over a typed prop. Prefer a typed prop, then `Box.extend()`.
|
|
44
|
+
27. **A floating layer is anchored in CSS**: `anchorName="trigger"`; on the layer `position="absolute"` + `positionAnchor="trigger"` + `positionArea="block-end span-all"` (block axis first, one keyword family). `positionTryFallbacks="flip-block"` flips, `positionVisibility="anchors-visible"` hides. A layer wider than its anchor needs `span-all`, or no flip fits. Portable path: rule 28.
|
|
45
|
+
28. **`useAnchorPosition` writes the placement**: `const { anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })` from `@box-kite/react/anchor`. Sizing props take `anchor-size(width)`, insets `anchor(bottom)`. A gap is `offset`, not a margin. `Overlay`, `Tooltip` and `Dropdown` take the same `side`/`align`/`offset`/`flip`.
|
|
46
|
+
29. **A panel with content is `<Popover>`**: `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}>…</Popover>` — the children are the panel, in the top layer, with the browser's light dismiss and focus return. The trigger must be a button, and never add an `onClick` toggle of your own. The panel is always rendered: gate heavy children on `open`; its exit is CSS, never `<Presence>`.
|
|
47
|
+
30. **Every floating layer is in the top layer, not portalled**: `Overlay`, `Tooltip`, `Dropdown` and the DataGrid menu inherit the theme, variables and direction where they are declared. NEVER declare a layer inside its trigger — put it beside it. A layer left open while the page scrolls keeps its side.
|
|
48
|
+
31. **A modal is `<Dialog>`**: `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>…</Dialog.Title>…</Dialog>` — `showModal()` supplies the backdrop, the inert page, Escape and focus. `Dialog.Title` (or `label`) names it. `<AlertDialog>` is a decision: no outside-press close, `initialFocus` on the least destructive action. `dismissible={false}` makes it unavoidable; the exit is CSS, never `<Presence>`.
|
|
49
|
+
32. **A menu button is `<Menu>`**: `Menu.Item onSelect`, `Menu.CheckboxItem`, `Menu.RadioGroup` + `Menu.RadioItem`, `Menu.Group label`, `Menu.Separator`, `Menu.Sub label`. A disabled item stays focusable. A command closes the menu, a checkbox or radio item does not (`closeOnSelect` swaps it). The semantic `<menu>` element is `MenuList` from `components/semantics`.
|
|
50
|
+
33. **Tabs are `<Tabs>`**: `<Tabs defaultValue="a"><Tabs.List label="Project"><Tabs.Tab value="a">…</Tabs.Tab></Tabs.List><Tabs.Panel value="a">…</Tabs.Panel></Tabs>`. Selection follows focus (`activation="manual"` splits them), and the list is one tab stop. Only the selected panel renders unless `keepMounted`. `indicator="sliding"` and `Tabs.Panels` animate. Style a selected tab with `ariaAttr={{ selected: … }}`.
|
|
51
|
+
34. **An accordion is `<Accordion>`; one disclosure is `Collapsible`** (same module): `<Accordion defaultValue={['a']}><Accordion.Item value="a"><Accordion.Trigger>…</Accordion.Trigger><Accordion.Panel>…</Accordion.Panel></Accordion.Item></Accordion>`. `multiple` allows several open; `level` is the heading level (default 3). Closed panels stay rendered, so gate heavy children yourself. Pad the panel, never `accordion.clip`.
|
|
52
|
+
35. **A slider is `<Slider>`, a bar is `<Progress>`**: `<Slider label="Volume" defaultValue={40} />`; an array value is a range, named with `thumbLabels`. `name` posts the value in a form. `onValueChange` fires per step, `onValueCommit` once. `<Progress label="Upload" value={62} />` renders on a server; no `value` means indeterminate.
|
|
53
|
+
36. **A message you send is `<Toaster />` + `toast()`**: mount `<Toaster />` once, then `toast.success('Saved')` from anywhere, even before it mounts. `toast.promise(p, { loading, success, error })`, `toast.update(id, …)`, `toast.dismiss(id?)`; options `description`, one `action`, `duration` (ms, `Infinity` waits), `onDismiss`. Past `limit` toasts queue.
|
|
54
|
+
37. **A DataGrid cell is edited with `column.editable` + `def.onCellEdit`**: `onCellEdit` validates — return nothing to accept, a string or `false` to refuse, or a promise. The editor follows the value or `column.editor` (`text`/`number`/`checkbox`/`select`/`date`/`time`/`datetime`); `column.EditCell` is your own. Accepted edits sit over `data` and arrive in `onCellEditsChange`; `clearEdits()` on the ref. An edit never re-sorts.
|
|
55
|
+
38. **A DataGrid range is `def.rangeSelection`**: drag or Shift+arrows mark a block, and Ctrl+C copies tab-separated text (one cell needs no opt-in). `onRangeChange(range, { reason })`. A copy uses `exportValue` and accepted edits. Styling: `isCurrentCell`, `isInRange`.
|
|
56
|
+
39. **A DataGrid paste is Ctrl+V through `onCellEdit`**: it fills from the current cell or block, tiling or spilling, and judges each cell separately — a refused cell is skipped and ringed. `onPaste({ range, applied, rejected, skipped })` reports the whole block. An unchanged value is not an edit.
|
|
57
|
+
40. **A UI a model writes is `catalog()` + `<SpecRenderer>`**: `catalog()` (`@box-kite/react/catalog`) is every component and value as JSON Schema; `specSchema(registry)` is what a model generates under (import it from `/catalog` in a route handler). `createSpecRegistry({ catalog, components })` is the allow-list; `<SpecRenderer spec registry data onAction onIssues />` renders. A node is `{ type, props, children, slots, on, repeat }`; `{ $data }`/`{ $item }`/`{ $index }` read `data`. Generate controlled props (`value`, `layout`), never `default…`.
|
|
58
|
+
41. **A dashboard people rearrange is `<DashboardGrid>` + `<Widget>`**: `layout` is `{ version, columns, items: [{ id, x, y, w, h }] }` with `onLayoutChange`/`onLayoutCommit` and `editable`. `columns` is a count per container size, and only the widest arrangement is editable. A `Widget` has `loading`/`error`/`empty` states and a `title` heading. `DashboardUtils.SCHEMA` and `parse()` read a stored or generated layout. Row height: `vars={{ 'dashboard-row': '8rem' }}`.
|
|
59
|
+
42. **A theme is an ancestor, and the nearest one owns the subtree**: `theme={{ dark: { bgColor: 'slate-900' } }}` nests anything. `<Box.Theme use="global">` themes `<html>`, `use="local"` a wrapper; `Box.useTheme()` reads and sets it. A hand-written theme class needs `data-theme` beside it.
|
|
60
|
+
43. **An agent's turn is `<ToolCallCard>`, `<ApprovalCard>` and `<Reasoning>`** (`components/agent`): `status` is `pending`/`running`/`success`/`error`; `onDecisionChange(decision, { reason })` answers an approval; `Reasoning` is closed by default (`open={streaming}` follows the stream). Values are formatted and capped, not trusted.
|
|
61
|
+
44. **Streamed text is `<StreamingText>`, `markdownComponents` and `<Skeleton>`**: pass `text` the whole message so far each render, never the delta. `markdownComponents` (`components/markdown`) is a constant map for react-markdown/Streamdown — override by spreading it at module scope, never inside render. `<Skeleton lines={3} />` is `aria-hidden` unless one `label` names the region.
|
|
62
|
+
45. **A runtime's tool calls are `toolPart()`, and A2UI is `a2uiToSpec()`** (`@box-kite/react/interop`, which imports no runtime): `toolPart` reads AI SDK, assistant-ui or CopilotKit parts; `applyToolEvent` folds AG-UI events; `a2uiApply` → `a2uiSurface` → `a2uiToSpec` renders A2UI; `a2uiCatalog(catalog())` goes the other way. Lift one component's schema with `a2uiComponentSchema(document, name)`, or its `$ref`s break.
|
|
63
|
+
46. **A scroll-driven animation is a timeline prop**: `animationTimeline="scroll()"` or `"view()"`; across elements, `scrollTimeline`/`viewTimeline` + `timelineScope`. `animationRange="entry 0% entry 50%"`. Always add `motionReduce={{ animation: 'none' }}` — it does not stop itself — and make the end state the resting state. Never put `animation` inside `css`: it resets the timeline.
|
|
64
|
+
47. **A view transition is `Box.viewTransition()` + `viewTransitionName`**: in React flush the update, `Box.viewTransition(() => flushSync(() => setTheme('dark')))`, or use `<Box.Theme viewTransition>`. A name must be unique while it runs, so a per-item name goes in `props={{ style: { viewTransitionName: id } }}`. Reduced motion skips it by default.
|
|
65
|
+
48. **In development, read the console**: a `[box-kite]` warning names what rendered and the line to write — a value no prop takes (with the nearest match), an HTML attribute at the top level, or a number in an unlikely unit (`fontSize={4}`, `lineHeight={1.5}`, `transitionDuration={0.3}`). `Box.configure({ warnings: false })` silences it; `check_styles` in `@box-kite/mcp` checks before rendering.
|
|
66
|
+
49. **A design for one subtree is `Box.createComponents()`**, not `Box.components()` (app-wide, merges): `const aurora = Box.createComponents({ … })` at module scope, then `<Box.Components value={aurora}>` or `<Box.Theme use="local" components={aurora}>`. Props still beat the design; a Server Component cannot read it.
|
|
67
|
+
50. **A list to read and pick from is `<List>`**: `List.Item` holds `List.Media`, `List.Content` (rows of `List.Title`, `List.Meta`, `List.Description`) and `List.End`. One `List.Action` in the title covers the row (`props={{ href }}` makes it a link); the item's own controls go in `List.Actions`. `variant="plain|card|separated"`, tuned with `vars`. Arrow-key selection is `Combobox`/`Select`, not `List`.
|
|
68
|
+
51. **On a phone a DataGrid is a list**: below `def.list.breakpoint` (576px of the grid's own width) rows render as `List` items, and `column.list` places a column (`title`, `kicker`, `subtitle`, `end`, `field`, `detail`, `hidden`). `layout: 'table'` or `'list'` forces one. No `overflow: hidden`/`auto` ancestor (`css={{ overflow: 'clip' }}` instead). In a test, `await loadListLayout()` first.
|
|
69
|
+
52. **Swiping, reordering and windowing are `SwipeItem`, `SortableList` and `VirtualList`**: `<SwipeItem endActions={…} fullSwipe>` (keep the commands pressable elsewhere too); `<SortableList onOrderChange>` — the app owns the order; `<VirtualList items itemKey>{(row) => <List.Item>…</List.Item>}</VirtualList>` returns exactly one `<li>` per item. Reorder short lists, window long ones.
|
|
70
|
+
53. **A date is an ISO string whose shape is its kind**: `2026-10-19` a date, `14:30:00` a time, `2026-10-19T14:30:00` a wall clock, `…Z`/`…+03:00` an instant — never hold a `Date`. `@box-kite/react/date` has `toUtc`, `toLocal`, `toZone`, `toPlainDate`, `format`, `parse`, `add`. `DateField`, `Calendar`, `DatePicker`/`DateRangePicker`, `TimeZoneSelect` and `DateTime` take and give that string; on a server, set the zone with `<DateProvider timeZone>` or `configureDates()`.
|
|
71
|
+
54. **A design is two families and a `Box.components()` object**: built-ins are drawn in `accent` and `base`, so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` recolours a subtree; write `accent-*`/`base-*` in your own components to follow it. `Box.components(styles, { design: 'ember' })` + `<Box.Theme design="ember">` / `Box.useDesign()` switch designs. A design removes a style with that prop's `none` (`bgGradient: 'none'`, never `bgImage`).
|
|
72
|
+
55. **Sizes are `compact`, `normal` and `large`, set by two variables**: `variant="compact"` on any control sizes it and everything inside. Define the three once: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}`. Never size one control by hand (`height={9}`); your own control uses `height="control"` + `fontSize="control"`. An icon button is `variant="square"` with an `aria-label` in `props`.
|
|
73
|
+
56. **An SVG shape turns about the viewBox's corner**: `<G rotate={45} transformBox="fill-box" transformOrigin="center">`. `pointerEvents="visibleStroke"` makes an edge's stroke its hit area; `touchAction="none"` keeps a drag from panning the page.
|
|
74
|
+
|
|
75
|
+
Full reference: `node_modules/@box-kite/react/.claude/skills/box-kite/references/rules.md` for any rule in depth, and `node_modules/@box-kite/react/docs/props.md` (or https://www.box-kite.dev/props.md) for every prop, the CSS it writes and one measured example.
|