@box-kite/react 2.1.0 → 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 +8 -2
- package/.claude/skills/box-kite/SKILL.md +11 -5
- package/.claude/skills/box-kite/references/patterns.md +1 -1
- package/.claude/skills/box-kite/references/styling.md +23 -0
- package/.cursor/rules/box-kite.mdc +9 -3
- package/AGENTS.md +9 -3
- package/BOX_AI_CONTEXT.md +171 -12
- package/BOX_KITE_AI_CONTEXT.md +171 -12
- package/README.md +2 -2
- package/box.cjs +1 -1
- package/box.d.ts +13 -2
- package/box.mjs +25 -18
- package/catalog.cjs +1 -1
- package/catalog.mjs +15 -1
- package/client.cjs +1 -1
- package/client.mjs +59 -51
- package/components/button.cjs +1 -1
- package/components/button.mjs +10 -14
- 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/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.mjs +9 -13
- 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/docs/mcp.md +6 -5
- package/docs/props.md +132 -20
- package/layer.cjs +1 -1
- package/layer.mjs +61 -59
- package/mcp/src/data.d.ts +1 -1
- package/mcp/src/styles.d.ts +2 -2
- package/mcp/src/vocabulary.d.ts +1 -1
- package/package.json +74 -48
- package/react/boxComponent.d.ts +10 -0
- package/react/popover/useTopLayer.d.ts +17 -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/rsc.cjs +1 -1
- package/rsc.d.ts +11 -1
- package/rsc.mjs +15 -12
- package/ssg.cjs +1 -1
- package/ssg.d.ts +8 -2
- package/ssg.mjs +13 -9
- package/utils/environment/environmentUtils.d.ts +5 -0
|
@@ -19,14 +19,14 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
19
19
|
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
|
|
20
20
|
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)
|
|
21
21
|
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)
|
|
22
|
-
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
|
|
22
|
+
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
|
|
23
23
|
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
|
|
24
24
|
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`
|
|
25
25
|
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
|
|
26
26
|
|
|
27
27
|
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
|
|
28
28
|
|
|
29
|
-
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
|
|
29
|
+
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
|
|
30
30
|
|
|
31
31
|
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
|
|
32
32
|
|
|
@@ -78,4 +78,10 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
78
78
|
|
|
79
79
|
45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.
|
|
80
80
|
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
81
87
|
Full reference: `src/BOX_KITE_AI_CONTEXT.md` or invoke `/box-kite` skill.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: box-kite
|
|
3
|
-
description: "@box-kite/react (Box Kite) expert — the runtime CSS-in-JS library whose Box component takes
|
|
3
|
+
description: "@box-kite/react (Box Kite) expert — the runtime CSS-in-JS library whose Box component takes 235 typed CSS props and writes the CSS itself. Use for any work with Box props, the Flex/Grid/Button/Textbox/Dropdown/DataGrid components, themes, Box.extend() or Box.components() — and read it before writing props, because the prop names collide with Tailwind's and Chakra's while the numbers mean different things. Also covers installing and updating the package."
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# @box-kite/react
|
|
8
8
|
|
|
9
|
-
Runtime CSS-in-JS. `Box` takes
|
|
9
|
+
Runtime CSS-in-JS. `Box` takes 235 typed CSS props and generates the CSS for them at runtime — no stylesheet, no class name to invent, and two elements written with the same value share one class.
|
|
10
10
|
|
|
11
11
|
## This library is not in your training data
|
|
12
12
|
|
|
@@ -40,7 +40,7 @@ The divider is per prop, so the same number is a different length depending on w
|
|
|
40
40
|
| [`references/components.md`](references/components.md) — Components reference | choosing a component, or wiring a Dropdown, a Select or a DataGrid |
|
|
41
41
|
| [`references/extending.md`](references/extending.md) — Extending Box | a value used twice, or a prop that does not exist yet |
|
|
42
42
|
| [`references/patterns.md`](references/patterns.md) — Patterns and the rest of the surface | server rendering, the behaviour hooks, portals, form controls, the tooltip |
|
|
43
|
-
| `node_modules/@box-kite/react/docs/props.md` | one prop exactly: all
|
|
43
|
+
| `node_modules/@box-kite/react/docs/props.md` | one prop exactly: all 235, the CSS each writes, one measured example |
|
|
44
44
|
| `node_modules/@box-kite/react/BOX_KITE_AI_CONTEXT.md` | the long-form reference, when a reference above is not enough |
|
|
45
45
|
|
|
46
46
|
With the package not installed, the prop reference is https://www.box-kite.dev/props.md, and https://www.box-kite.dev/llms.txt indexes every documentation page as markdown beside it.
|
|
@@ -92,14 +92,14 @@ Detect the package manager from the lock file rather than asking:
|
|
|
92
92
|
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
|
|
93
93
|
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)
|
|
94
94
|
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)
|
|
95
|
-
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
|
|
95
|
+
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
|
|
96
96
|
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
|
|
97
97
|
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`
|
|
98
98
|
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
|
|
99
99
|
|
|
100
100
|
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
|
|
101
101
|
|
|
102
|
-
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
|
|
102
|
+
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
|
|
103
103
|
|
|
104
104
|
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
|
|
105
105
|
|
|
@@ -151,4 +151,10 @@ Detect the package manager from the lock file rather than asking:
|
|
|
151
151
|
|
|
152
152
|
45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.
|
|
153
153
|
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
154
160
|
Full reference: `references/` beside this file, and `docs/props.md` inside the package.
|
|
@@ -13,7 +13,7 @@ The recipes, plus the parts of the library that are not props: server rendering,
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
**Group hover**: `<Box group={{ 'parent-class/hover': { opacity: 1 } }}>`
|
|
16
|
-
**SSR**: `import {
|
|
16
|
+
**SSR**: `import { getStyleTag, resetStyles } from '@box-kite/react/ssg'` — render, put `getStyleTag()` in the `<head>` (it is `<style id="box-kite-styles" data-box-kite="…">`), then `resetStyles()`. Needs no DOM. With `Box.configure({ classNames: 'stable' })` on **both** sides the browser **adopts** that sheet on hydration — no rule regenerated or reinserted, no sheet swapped — so never remove it after hydrating; without stable names it is left in place and the engine writes its own after it. `getStyles()` is the bare CSS.
|
|
17
17
|
**Server Components (React 19)**: works with no `'use client'` and no setup — the `react-server` entry renders Box hook-free and its CSS ships as `<style href precedence>` elements React hoists into `<head>`. Client components in the same app: `Box.configure({ sink: 'element' })` once in a `'use client'` module. Hook-free components (`Flex`, `Grid`, `Button`, `Textbox`, `Textarea`, `RadioButton`, `Icon`, the SVG elements, semantic tags) render on the server too — import them straight into a Server Component; `Dropdown`/`Tooltip`/`Overlay`/`Popover`/`Dialog`/`DataGrid`/`Checkbox`/`Switch`/`RadioGroup`/`Select`/`Form` ship a `'use client'` banner, so importing one just opens a client boundary. Client-only: hover-callback children, `Box.Theme` — but `theme={{ dark: {...} }}` styles work server-side (set the theme class on `<html>`). Element-mode rules are in `@layer`, so your own unlayered CSS wins. React 18: keep the default sink.
|
|
18
18
|
**Accessible behaviour** (`@box-kite/react/a11y`, 2.2 KB gz, client-only): `useControllableState` (controlled/uncontrolled with `onChange(value, { reason, event })` — `'escape'`, `'outside-pointer'`, yours), `useDismiss({ enabled, inside: [triggerRef, popupRef], onDismiss })` (Escape reaches the innermost layer only; pass the trigger or it dismisses and reopens in one press), `useFocusReturn({ enabled, returnTo })`, `useRovingFocus({ count, orientation, textOf, onSelect })` → `{ activeIndex, onKeyDown, itemProps(i), activeItem() }` (arrows, Home/End, typeahead, disabled skipped; `focusItems: false` for `aria-activedescendant`), `useIdentifier('select')` (React's `useId`, usable as a selector). They supply mechanics, never roles or ARIA. `VisuallyHidden` (`@box-kite/react/components/visuallyHidden`) is a Box that clips itself away instead of hiding, so it stays in the accessibility tree — and it renders on a server.
|
|
19
19
|
**Config**: `Box.configure({ sink: 'cssom' | 'textContent' | 'string' | 'element', classNames: 'hashed' | 'readable' | 'stable' })` — before the first render.
|
|
@@ -87,6 +87,29 @@ child's own CSS says the transition is over, then lets React remove it. `ref` go
|
|
|
87
87
|
`height: auto` animate inside it (inherited; Chromium-only, elsewhere it snaps). `Box.configure({ transition: 'colors' | false })` changes
|
|
88
88
|
what the base class transitions, before the first render.
|
|
89
89
|
|
|
90
|
+
**Scroll-driven animation & view transitions**: `animationTimeline` takes the animation's progress off a scroll position instead of a
|
|
91
|
+
clock — `'scroll()'` is the nearest scrollport's progress (`'scroll(root)'` the page's, `'scroll(self inline)'` an axis and a scroller
|
|
92
|
+
named in either order), `'view()'` is this element's own pass across it, and both need nothing declared anywhere else. A named timeline
|
|
93
|
+
is the two-sided form: `scrollTimeline="page block"` on the scroller or `viewTimeline="card block"` on the subject, `animationTimeline="page"`
|
|
94
|
+
on whatever animates, and `timelineScope="page"` on a common ancestor when the animated element is not inside the declaring one (a
|
|
95
|
+
timeline is otherwise visible only to its descendants). The `--` on a name is optional and added for you; the axis is `'block'`/`'inline'`/`'x'`/`'y'`;
|
|
96
|
+
`scrollTimelineName`/`scrollTimelineAxis` and `viewTimelineName`/`viewTimelineAxis` are the longhands. **`animationRange`** picks the
|
|
97
|
+
part of the pass the animation occupies — `'cover'`, `'contain'`, `'entry'`, `'exit'`, `'entry-crossing'`, `'exit-crossing'`, each taking an
|
|
98
|
+
offset (`animationRange="entry 0% entry 50%"`, or `animationRangeStart`/`animationRangeEnd` one end at a time), and `viewTimelineInset`
|
|
99
|
+
(`'auto'`, or one or two lengths) shrinks the scrollport it is measured against so a reveal fires before the element reaches the edge.
|
|
100
|
+
`<Box animation="fade-up" animationTimeline="view()" animationRange="entry 0% entry 60%" motionReduce={{ animation: 'none' }} />` is a
|
|
101
|
+
reveal-on-scroll with no JavaScript. Three traps: the CSS `animation` **shorthand resets `animation-timeline`** (the registry declares the
|
|
102
|
+
timeline after it, so the props are safe — an `animation` inside `css` is not); a scroll-driven animation has **no duration**, so
|
|
103
|
+
`--transitionTime` cannot zero it and it is the one motion here that does not stop itself under reduced motion; and where the browser has
|
|
104
|
+
neither (Safari, Firefox behind a flag) the _declaration_ is dropped, not the animation, so it plays on the document timeline — make the
|
|
105
|
+
end state the resting state. **View transitions** are `viewTransitionName` (a `<custom-ident>`, **not** dashed, unique in the document
|
|
106
|
+
while the transition runs) and `viewTransitionClass` (a shared name, so `::view-transition-group(.card)` reaches a set), with
|
|
107
|
+
`Box.viewTransition(update, { reducedMotion, types })` around the change itself: it feature-detects, hands back the same
|
|
108
|
+
`ready`/`finished`/`updateCallbackDone`/`skip()` handle whether or not a transition ran, and **skips under reduced motion while still
|
|
109
|
+
applying the update**. In React the update must be flushed inside the callback — `Box.viewTransition(() => flushSync(() => setTab(next)))` —
|
|
110
|
+
or the screenshot catches the old state twice; `<Box.Theme viewTransition>` is that already done for a theme switch. A name _per list item_
|
|
111
|
+
is a rule per item that is never freed, so that one belongs in `props={{ style: { viewTransitionName: id } }}`.
|
|
112
|
+
|
|
90
113
|
**Gradients**: `bgGradient` — a gradient as a _value_, written as a record, so its stops are palette tokens and it is
|
|
91
114
|
themed, takes the opacity modifier and shares one class. The key names the kind and carries its geometry:
|
|
92
115
|
`{ linear: 'r', colors: ['blue-500', 'pink-500'] }` (a direction `t`/`tr`/`r`/`br`/`b`/`bl`/`l`/`tl`, or a number of
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: @box-kite/react (Box Kite): the runtime CSS-in-JS library whose Box component takes
|
|
2
|
+
description: @box-kite/react (Box Kite): the runtime CSS-in-JS library whose Box component takes 235 typed CSS props. Read before writing Box props, a component, a theme or an extension — the prop names collide with Tailwind and Chakra while the numbers mean different things.
|
|
3
3
|
globs: **/*.tsx,**/*.jsx,**/*.ts
|
|
4
4
|
alwaysApply: false
|
|
5
5
|
---
|
|
@@ -27,14 +27,14 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
27
27
|
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
|
|
28
28
|
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)
|
|
29
29
|
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)
|
|
30
|
-
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
|
|
30
|
+
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
|
|
31
31
|
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
|
|
32
32
|
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`
|
|
33
33
|
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
|
|
34
34
|
|
|
35
35
|
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
|
|
36
36
|
|
|
37
|
-
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
|
|
37
|
+
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
|
|
38
38
|
|
|
39
39
|
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
|
|
40
40
|
|
|
@@ -86,4 +86,10 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
86
86
|
|
|
87
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
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. **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
|
+
|
|
89
95
|
Full reference: `node_modules/@box-kite/react/docs/props.md` in the project, or https://www.box-kite.dev/props.md — every prop, the CSS it writes and one measured example.
|
package/AGENTS.md
CHANGED
|
@@ -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
|
|
|
@@ -59,14 +59,14 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
59
59
|
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
|
|
60
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)
|
|
61
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)
|
|
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
|
|
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
|
|
63
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
|
|
64
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`
|
|
65
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
|
|
66
66
|
|
|
67
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
|
|
68
68
|
|
|
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. 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
|
|
70
70
|
|
|
71
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
|
|
72
72
|
|
|
@@ -118,4 +118,10 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
118
118
|
|
|
119
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
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
|
+
|
|
121
127
|
Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.
|
package/BOX_AI_CONTEXT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @box-kite/react - AI Assistant Context
|
|
2
2
|
|
|
3
|
-
Runtime CSS-in-JS library. `Box` component accepts
|
|
3
|
+
Runtime CSS-in-JS library. `Box` component accepts 235 CSS props and generates CSS classes at runtime. Same prop values share a single class.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -282,9 +282,13 @@ const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', ali
|
|
|
282
282
|
The palette is **Tailwind 4.3's, in OKLCH**: twenty-six families of eleven steps (`50`–`950`) — the five
|
|
283
283
|
neutrals (`slate`, `gray`, `zinc`, `neutral`, `stone`), the four Tailwind 4.3 added (`mauve`, `mist`,
|
|
284
284
|
`olive`, `taupe`) and seventeen hues (`red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`,
|
|
285
|
-
`teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`).
|
|
286
|
-
CSS variable
|
|
287
|
-
nothing
|
|
285
|
+
`teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`). A token is a
|
|
286
|
+
CSS variable written **with its own value as the fallback** — `bgColor="sky-500"` is
|
|
287
|
+
`background-color: var(--sky-500,oklch(68.5% .169 237.3))` — so nothing is declared in `:root` (a late
|
|
288
|
+
`:root` insert recalculates every element on the page) and declaring `--sky-500` yourself still overrides
|
|
289
|
+
it. `var(--sky-500)` elsewhere in this file is that, shortened. Your own CSS cannot count on `--sky-500`
|
|
290
|
+
existing: hand it a variable of yours with `vars={{ brand: 'violet-500' }}` on an ancestor. A hand-written
|
|
291
|
+
`var(--sky-500)` _inside_ a Box value — `fill`, `vars`, `css`, a `Box.extend()` variable — gets the fallback too.
|
|
288
292
|
|
|
289
293
|
Any colour value takes a slash and a percentage:
|
|
290
294
|
|
|
@@ -642,6 +646,131 @@ const wobble = Box.spring({ stiffness: 120, damping: 8 });
|
|
|
642
646
|
|
|
643
647
|
---
|
|
644
648
|
|
|
649
|
+
### Scroll-driven animations
|
|
650
|
+
|
|
651
|
+
An animation's progress can come from a scroll position rather than a clock. There is no listener, no
|
|
652
|
+
`requestAnimationFrame` and no state: the browser runs it off the compositor, and the whole thing is props.
|
|
653
|
+
|
|
654
|
+
| Prop | CSS Property | Notes |
|
|
655
|
+
| ------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
656
|
+
| `animationTimeline` | animation-timeline | `'none'`, `'auto'`, an anonymous timeline (`'scroll()'`, `'scroll(root block)'`, `'view()'`, `'view(inline 20%)'`) or a name a declaration below gave |
|
|
657
|
+
| `animationRange` | animation-range | One end or both: `'entry'`, `'cover 20%'`, `'entry 0% entry 100%'`, `'normal'` |
|
|
658
|
+
| `animationRangeStart` / `animationRangeEnd` | animation-range-start / -end | One end at a time. A named part of the pass with an optional offset, a bare length, or `'normal'` |
|
|
659
|
+
| `scrollTimeline` | scroll-timeline | Declares a named timeline on a **scroller**: `'page'` or `'page block'`. The `--` is optional and added for you |
|
|
660
|
+
| `scrollTimelineName` / `scrollTimelineAxis` | scroll-timeline-name / -axis | The two halves of that shorthand. Axis: `'block'`, `'inline'`, `'x'`, `'y'` |
|
|
661
|
+
| `viewTimeline` | view-timeline | Declares a named timeline on a **subject** — its own pass across the scrollport: `'card'` or `'card block'` |
|
|
662
|
+
| `viewTimelineName` / `viewTimelineAxis` | view-timeline-name / -axis | The two halves of that one |
|
|
663
|
+
| `viewTimelineInset` | view-timeline-inset | `'auto'`, or one or two lengths, shrinking the scrollport the pass is measured against |
|
|
664
|
+
| `timelineScope` | timeline-scope | Makes named timelines below this element reachable from anywhere else below it. Comma-separated |
|
|
665
|
+
|
|
666
|
+
The two anonymous timelines cover most of it and need nothing declared elsewhere:
|
|
667
|
+
|
|
668
|
+
```tsx
|
|
669
|
+
// A reading-progress bar: the page's own scroll, driving a scaleX from 0 to 1.
|
|
670
|
+
Box.keyframes({ progress: { from: { scale: 0 }, to: { scale: 1 } } });
|
|
671
|
+
|
|
672
|
+
<Box
|
|
673
|
+
position="fixed"
|
|
674
|
+
top={0}
|
|
675
|
+
insetX={0}
|
|
676
|
+
height={1}
|
|
677
|
+
bgColor="sky-500"
|
|
678
|
+
css={{ transformOrigin: 'left' }}
|
|
679
|
+
animationName="progress"
|
|
680
|
+
animationTimeline="scroll(root)"
|
|
681
|
+
animationFillMode="both"
|
|
682
|
+
motionReduce={{ animation: 'none' }}
|
|
683
|
+
/>;
|
|
684
|
+
|
|
685
|
+
// Reveal on scroll: this element's own pass, over the first 60% of its entry.
|
|
686
|
+
Box.keyframes({ 'fade-up': { from: { opacity: 0, translateY: 4 }, to: { opacity: 1, translateY: 0 } } });
|
|
687
|
+
|
|
688
|
+
<Box animationName="fade-up" animationTimeline="view()" animationRange="entry 0% entry 60%" motionReduce={{ animation: 'none' }}>
|
|
689
|
+
…
|
|
690
|
+
</Box>;
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
A **named** timeline is the two-sided form, for when the animated element is not inside the scroller:
|
|
694
|
+
|
|
695
|
+
```tsx
|
|
696
|
+
<Box timelineScope="page">
|
|
697
|
+
<Box height="fit-screen" overflow="auto" scrollTimeline="page block">
|
|
698
|
+
…
|
|
699
|
+
</Box>
|
|
700
|
+
<Box animationName="progress" animationTimeline="page" animationFillMode="both" />
|
|
701
|
+
</Box>
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
**Three things to know, and all three are traps:**
|
|
705
|
+
|
|
706
|
+
- **The CSS `animation` shorthand resets `animation-timeline`.** A timeline declared before it is
|
|
707
|
+
silently undone. The registry declares `animationTimeline` after `animation`, so writing both props is
|
|
708
|
+
safe in either order — but an `animation` written inside `css` sorts last and will undo it.
|
|
709
|
+
- **A scroll-driven animation has no duration**, so `--transitionTime` cannot zero it. It is the one kind
|
|
710
|
+
of motion in this library that does **not** stop itself under `prefers-reduced-motion`: pair it with
|
|
711
|
+
`motionReduce={{ animation: 'none' }}`, every time.
|
|
712
|
+
- **Where the browser has none, the declaration is dropped rather than the animation.** It then runs on the
|
|
713
|
+
document timeline over whatever duration it was given. Make the end state the resting state — with
|
|
714
|
+
`animationFillMode="both"` and a sequence that ends where the element belongs, a missing timeline degrades
|
|
715
|
+
to "already arrived" rather than to a loop. Chrome/Edge 115+, Safari 26+; Firefox is behind a flag.
|
|
716
|
+
|
|
717
|
+
---
|
|
718
|
+
|
|
719
|
+
### View transitions
|
|
720
|
+
|
|
721
|
+
The browser screenshots the page, runs the update, screenshots again, and cross-fades between the two —
|
|
722
|
+
so a change that was a jump becomes a transition with nothing animated by hand.
|
|
723
|
+
|
|
724
|
+
| Prop / API | What it does |
|
|
725
|
+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
726
|
+
| `viewTransitionName` | Names this element, so it animates _from where it was to where it is_ instead of being cross-faded with the page. A `<custom-ident>`, **not** dashed |
|
|
727
|
+
| `viewTransitionClass` | A name shared by several elements, so `::view-transition-group(.card)` styles them together. Space-separated for more than one |
|
|
728
|
+
| `Box.viewTransition(update, options)` | Runs `update` inside `document.startViewTransition` where the browser has one, and plainly where it has not |
|
|
729
|
+
| `<Box.Theme viewTransition>` | That call around a theme change, `flushSync` included |
|
|
730
|
+
|
|
731
|
+
```tsx
|
|
732
|
+
const handle = Box.viewTransition(() => flushSync(() => setTab(next)), { types: ['forward'] });
|
|
733
|
+
await handle.finished;
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
interface ViewTransitionOptions {
|
|
738
|
+
/** `'skip'` (default) runs the update with no transition under reduced motion; `'play'` transitions anyway. */
|
|
739
|
+
reducedMotion?: 'skip' | 'play';
|
|
740
|
+
/** Types `:active-view-transition-type(name)` selects on, so forward and back can differ. */
|
|
741
|
+
types?: string[];
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
interface ViewTransitionHandle {
|
|
745
|
+
ready: Promise<void>;
|
|
746
|
+
finished: Promise<void>;
|
|
747
|
+
updateCallbackDone: Promise<void>;
|
|
748
|
+
skip(): void;
|
|
749
|
+
/** False when the browser had no API, or reduced motion skipped it. */
|
|
750
|
+
transitioned: boolean;
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
**Four things to know:**
|
|
755
|
+
|
|
756
|
+
- **In React the update has to be flushed.** The browser takes its second screenshot the moment the
|
|
757
|
+
callback returns, and a `setState` has not rendered by then — so a hand-rolled version captures the old
|
|
758
|
+
state twice and nothing appears to move. `flushSync` inside the callback is the fix, and
|
|
759
|
+
`<Box.Theme viewTransition>` is it already done for the one change every app has.
|
|
760
|
+
- **A name has to be unique in the document while the transition runs.** Two elements sharing one is how a
|
|
761
|
+
transition silently does nothing. The prop is for the handful of names a layout has (`header`, `main`);
|
|
762
|
+
a name **per list item** is a rule per item that is never freed, so that one goes in
|
|
763
|
+
`props={{ style: { viewTransitionName: id } }}` — the exception `useAnchorPosition`'s anchor name and a
|
|
764
|
+
`Slider` thumb already take.
|
|
765
|
+
- **Reduced motion skips the transition and keeps the update.** A whole-page cross-fade is exactly the
|
|
766
|
+
motion the preference is about. `{ reducedMotion: 'play' }` is the opt-out, for a change that is
|
|
767
|
+
unreadable without one.
|
|
768
|
+
- **The handle is the same shape either way**, so there is one code path: with no API, `finished` is an
|
|
769
|
+
already-resolved promise, `skip()` a no-op and `transitioned` false. `ready` rejects when a transition is
|
|
770
|
+
skipped, which is not an error — branch on `finished`.
|
|
771
|
+
|
|
772
|
+
---
|
|
773
|
+
|
|
645
774
|
## Pseudo-Classes, Breakpoints & Themes
|
|
646
775
|
|
|
647
776
|
```tsx
|
|
@@ -1119,24 +1248,41 @@ second argument for an element inside an `<svg>`. `<Icon>` is this hook plus tha
|
|
|
1119
1248
|
### Server-Side Rendering
|
|
1120
1249
|
|
|
1121
1250
|
```tsx
|
|
1122
|
-
import
|
|
1251
|
+
import Box from '@box-kite/react';
|
|
1252
|
+
import { getStyleTag, resetStyles } from '@box-kite/react/ssg';
|
|
1253
|
+
|
|
1254
|
+
Box.configure({ classNames: 'stable' }); // in the server AND in the browser entry
|
|
1123
1255
|
|
|
1124
1256
|
const html = renderToString(<App />); // any React server renderer
|
|
1125
|
-
const
|
|
1126
|
-
// <style id="box-kite-styles">{cssString}</style> in the document head
|
|
1257
|
+
const styleTag = getStyleTag(); // <style id="box-kite-styles" data-box-kite="…">…</style>, for the head
|
|
1127
1258
|
resetStyles(); // reset before the next request
|
|
1128
1259
|
```
|
|
1129
1260
|
|
|
1130
|
-
Or in one call, which injects the
|
|
1261
|
+
Or in one call, which injects the tag into the rendered `<head>` and resets afterwards:
|
|
1131
1262
|
|
|
1132
1263
|
```tsx
|
|
1133
1264
|
import { renderToStaticMarkup } from '@box-kite/react/ssg';
|
|
1134
1265
|
|
|
1135
|
-
const { html, styles } = renderToStaticMarkup(<App />);
|
|
1266
|
+
const { html, styles, styleTag } = renderToStaticMarkup(<App />);
|
|
1136
1267
|
```
|
|
1137
1268
|
|
|
1138
1269
|
No DOM is needed: with no `document` in the process the engine collects CSS in memory. Sequential
|
|
1139
|
-
requests are independent and identical markup gets identical class names.
|
|
1270
|
+
requests are independent and identical markup gets identical class names. `getStyles()` is the bare CSS.
|
|
1271
|
+
|
|
1272
|
+
**Adoption.** The `data-box-kite` attribute is a manifest — each rule's cascade position and a checksum
|
|
1273
|
+
of the classes it styles. On the first render the browser engine checks it against the sheet it parsed
|
|
1274
|
+
and takes the sheet over: every rule in it counts as generated, so hydration builds and inserts nothing
|
|
1275
|
+
for what the HTML already styled, and only rules the client adds afterwards are inserted, each in its
|
|
1276
|
+
cascade position. Two conditions:
|
|
1277
|
+
|
|
1278
|
+
| Condition | Why | If it does not hold |
|
|
1279
|
+
| --------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
1280
|
+
| `classNames: 'stable'` on both sides | the content-hashed names are how the browser recognises the server's rules | the sheet is left in place under no id and the engine writes its own after it |
|
|
1281
|
+
| the browser parsed every generated rule | an index is only trustworthy if nothing in between was dropped | same fallback |
|
|
1282
|
+
|
|
1283
|
+
Do **not** remove the server's `<style>` after hydration — it is the engine's own sheet now. Register
|
|
1284
|
+
the same `Box.extend()` props and `Box.keyframes()` sequences on both sides. `engine.getStyleTag()` is
|
|
1285
|
+
the same thing for `@box-kite/core`.
|
|
1140
1286
|
|
|
1141
1287
|
---
|
|
1142
1288
|
|
|
@@ -2139,7 +2285,7 @@ function TrendCell({ cell }: { cell: CellModel<Row> }) {
|
|
|
2139
2285
|
9. **Theme styles nest**: `theme={{ dark: { hover: { ... } } }}`
|
|
2140
2286
|
10. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`. `data-*` and `aria-*` go there too — a `data-state` written at the top level typechecks and is then dropped
|
|
2141
2287
|
11. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
|
|
2142
|
-
12. **Box is memoized** with `React.memo`
|
|
2288
|
+
12. **Box is memoized** with `React.memo`, and so are `Flex`, `H1`, `Button` and the rest. A component of your own that is a Box with defaults calls `useBoxElement({ p: 4, ...props }, ref)` from its own `forwardRef` rather than returning `<Box>` — one fiber instead of two
|
|
2143
2289
|
13. **`style` is top-level only** — never inside breakpoints (`sm={{ style: ... }}`), pseudo-classes, or theme objects. `css` is the one that nests: `sm={{ css: { objectPosition: 'top' } }}` is a rule
|
|
2144
2290
|
14. **A CSS variable is a prop**: `vars={{ 'color-x': 'sky-500' }}` declares `--color-x` for everything inside — and a third-party chart goes in `<ChartContainer series={['revenue']}>` so it names no colour at all
|
|
2145
2291
|
15. **All sizing props use divider 4** — `width`, `height`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight` are NOT direct pixels. `height={10}` = 2.5rem = 40px
|
|
@@ -2896,6 +3042,19 @@ Also accepts: `data` (TRow[]), `value`/`defaultValue`, `label`/`labelProps`, `mu
|
|
|
2896
3042
|
3. **CSS variables**: In `:root` rules
|
|
2897
3043
|
4. **Theme issues**: Ensure `<Box.Theme>` wraps your app
|
|
2898
3044
|
5. **Layer theming**: a tooltip or dropdown popup stays where it was declared and inherits the theme; only the no-Popover-API fallback uses the `#box-kite-portal` container
|
|
3045
|
+
6. **Read the console first**: outside a production build the engine prints one `[box-kite]` warning per mistake that would otherwise be silent, each naming the fix:
|
|
3046
|
+
|
|
3047
|
+
| You wrote | The warning says |
|
|
3048
|
+
| -------------------------- | -------------------------------------------------------------------------- |
|
|
3049
|
+
| `bgColor="bleu-500"` | wrote no CSS … Did you mean `"blue-500"`? |
|
|
3050
|
+
| `opacity={50}` | wrote no CSS … It takes one of 0, 0.1, … 1 |
|
|
3051
|
+
| `<Link href="/about">` | `href` is an HTML attribute … write `props={{ href: ... }}` |
|
|
3052
|
+
| `fontSize={4}` | is 4px text … For 16px write `fontSize={16}` |
|
|
3053
|
+
| `lineHeight={1.5}` | is a 1.5px line … `lineHeight="font-size"`, or `css={{ lineHeight: 1.5 }}` |
|
|
3054
|
+
| `transitionDuration={0.3}` | is 0.3ms … For 0.3s write `transitionDuration={300}` |
|
|
3055
|
+
| `brightness={1.1}` | is 1.1% … For 110% write `brightness={110}` |
|
|
3056
|
+
|
|
3057
|
+
Once per value, and `Box.configure({ warnings: false })` silences them. In production a rejected value is dropped with nothing said, which is why `check_styles` (`@box-kite/mcp`) exists
|
|
2899
3058
|
|
|
2900
3059
|
---
|
|
2901
3060
|
|
|
@@ -3204,7 +3363,7 @@ allowed.tokens; // the colours, the @keyframes names and the style-tree nodes a
|
|
|
3204
3363
|
`DataGrid`'s `def` (required — the columns and the grid-wide flags) and `data`, `DashboardGrid`'s
|
|
3205
3364
|
`layout`/`defaultLayout`/`columns`, `Widget`'s `empty`, `ChartContainer`'s `series`. A column's nested
|
|
3206
3365
|
`columns`, and `dataSource`/`onCellEdit`/`rowDetail`/`treeData`, stay out — they are the app's to pass.
|
|
3207
|
-
- **The allow-list is the app's.** `catalog()` with no options is all 78 components and all
|
|
3366
|
+
- **The allow-list is the app's.** `catalog()` with no options is all 78 components and all 235 props —
|
|
3208
3367
|
~4.6 MB serialized. `include`, `exclude` and `styleProps` are how an app narrows it.
|
|
3209
3368
|
|
|
3210
3369
|
Full reference: `docs/catalog.md`.
|