@box-kite/react 2.2.18 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.claude/rules/box-kite-rules.md +6 -2
  2. package/.claude/skills/box-kite/SKILL.md +19 -15
  3. package/.claude/skills/box-kite/references/patterns.md +1 -1
  4. package/.claude/skills/box-kite/references/styling.md +3 -3
  5. package/.cursor/rules/box-kite.mdc +7 -3
  6. package/AGENTS.md +7 -3
  7. package/BOX_AI_CONTEXT.md +81 -41
  8. package/BOX_KITE_AI_CONTEXT.md +81 -41
  9. package/README.md +13 -7
  10. package/behavior.cjs +1 -1
  11. package/behavior.mjs +1 -1
  12. package/box.cjs +1 -1
  13. package/box.d.ts +1 -0
  14. package/box.mjs +1 -1
  15. package/catalog.cjs +1 -1
  16. package/catalog.mjs +4 -1
  17. package/client.cjs +1 -1
  18. package/client.mjs +14 -14
  19. package/components/calendar.cjs +1 -1
  20. package/components/calendar.mjs +21 -20
  21. package/components/dashboard.cjs +1 -1
  22. package/components/dashboard.mjs +12 -12
  23. package/components/dataGrid/components/dataGridFooter.d.ts +6 -2
  24. package/components/dataGrid/models/gridModel.d.ts +21 -2
  25. package/components/dataGrid/models/groupRowModel.d.ts +1 -1
  26. package/components/dataGrid/models/rowModel.d.ts +1 -1
  27. package/components/dataGrid/models/sourceGroupRowModel.d.ts +5 -1
  28. package/components/dataGrid/models/sourceRowModel.d.ts +5 -1
  29. package/components/dataGrid.cjs +3 -3
  30. package/components/dataGrid.mjs +821 -771
  31. package/components/dateField.cjs +1 -1
  32. package/components/dateField.mjs +6 -6
  33. package/components/datePicker.cjs +1 -1
  34. package/components/datePicker.mjs +116 -111
  35. package/components/dialog.cjs +1 -1
  36. package/components/dialog.d.ts +1 -1
  37. package/components/dialog.mjs +1 -1
  38. package/components/dropdown.cjs +1 -1
  39. package/components/dropdown.mjs +8 -3
  40. package/components/menu.cjs +1 -1
  41. package/components/menu.mjs +10 -10
  42. package/components/overlay.cjs +1 -1
  43. package/components/overlay.mjs +6 -6
  44. package/components/popover.cjs +1 -1
  45. package/components/popover.d.ts +8 -2
  46. package/components/popover.mjs +58 -55
  47. package/components/slider.cjs +1 -1
  48. package/components/slider.mjs +8 -8
  49. package/components/sortableList.cjs +1 -1
  50. package/components/sortableList.mjs +1 -1
  51. package/components/svg.d.ts +2 -2
  52. package/components/swipeItem.cjs +1 -1
  53. package/components/swipeItem.mjs +11 -11
  54. package/components/tabs.cjs +1 -1
  55. package/components/tabs.mjs +9 -9
  56. package/dataGridList.cjs +1 -1
  57. package/dataGridList.mjs +3 -5
  58. package/docs/props.md +49 -25
  59. package/interactionListening.cjs +1 -1
  60. package/interactionListening.mjs +3 -3
  61. package/layer.cjs +1 -1
  62. package/layer.mjs +1 -1
  63. package/package.json +5 -5
  64. package/placement.cjs +1 -1
  65. package/placement.mjs +1 -1
  66. package/platform.cjs +1 -1
  67. package/platform.mjs +16 -11
  68. package/react-shared.cjs +1 -1
  69. package/react-shared.mjs +1 -1
  70. package/rsc.cjs +1 -1
  71. package/rsc.d.ts +1 -0
  72. package/rsc.mjs +1 -1
  73. package/utils/dom/domUtils.d.ts +2 -0
@@ -14,7 +14,7 @@ globs: '**/*.{ts,tsx,jsx}'
14
14
  7. **Border width and lineHeight are direct px**: `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
15
15
  8. **A gradient fill is a value, not an attribute**: `fill="url(#sky)"`, `stroke="var(--chart-1)"`, `clipPath="url(#frame)"` — so it can be themed and hovered. **A dashboard shape is `components/chart`**: `<Sparkline data={[…]}/>`, `<ProgressRing value={0.6}/>`, `<Gauge>`, `<MiniDonut>` — Box props, no chart library
16
16
  9. **A CSS variable is a prop too**: `vars={{ 'color-revenue': 'sky-500' }}` declares `--color-revenue` on the element and everything inside it — the answer for markup this library does not render. **A third-party chart goes in `<ChartContainer series={['revenue', 'cost']}>`** (`components/chart`), which declares `--chart-1..6` in both themes and one `--color-<series>` per series, so `<Line stroke="var(--color-revenue)"/>` is all the chart ever says about colour. A record names its own paints, as anything `fill` takes: `series={{ done: 'ui-primary', open: 'ui-primary/35' }}` works with a `Box.extend()` token too.
17
- 10. **SVG lengths have no divider and no unit**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset` and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y`. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
17
+ 10. **SVG lengths have no divider**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset`, and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y` write the number as `px`, which is a user unit (`r={20}` → `r: 20px`) — Firefox and Safari drop a bare number there. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
18
18
  11. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`
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)
@@ -44,7 +44,7 @@ globs: '**/*.{ts,tsx,jsx}'
44
44
 
45
45
  28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
46
46
 
47
- 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus into the panel on open (`autoFocus`), and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
47
+ 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
48
48
 
49
49
  30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
50
50
 
@@ -97,3 +97,7 @@ globs: '**/*.{ts,tsx,jsx}'
97
97
  Full reference: `src/BOX_KITE_AI_CONTEXT.md` or invoke `/box-kite` skill.
98
98
 
99
99
  54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
100
+
101
+ 55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
102
+
103
+ 56. **An SVG shape turns about the viewBox's corner, not its own centre.** Its `transform-box` is `view-box`, so `rotate={45}` alone swings it round `0 0`: write `<G rotate={45} transformBox="fill-box" transformOrigin="center">`. `transformOrigin` takes a keyword (`'top left'`), a fraction or a percentage, a ÷4 number, or positions as CSS writes them (`'48px 48px'` — a px is a user unit). The `transform` attribute stays for a matrix or an angle that is data. Two more props an interactive drawing needs: `pointerEvents="visibleStroke"` makes an edge's stroke its hit area (over a wide transparent one), and `touchAction="none"` on the pressed element keeps a finger's drag from panning the page.
@@ -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 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."
3
+ description: "@box-kite/react (Box Kite) expert — the runtime CSS-in-JS library whose Box component takes CSS properties as typed 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 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.
9
+ Runtime CSS-in-JS. `Box` takes CSS properties as typed 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
 
@@ -21,16 +21,16 @@ It was renamed at 1.0.0 (September 2026) and its prop surface nearly doubled on
21
21
 
22
22
  The divider is per prop, so the same number is a different length depending on which prop it is written on — `p={4}` is 16px and `b={4}` is 4px. Every row here is what the engine emitted for that example, not what a doc claims about it:
23
23
 
24
- | Prop | Which props | Written | Emitted |
25
- | ------------------- | ---------------------------------------------------------- | -------------------------- | ---------------------------- |
26
- | `p` | spacing — `p`/`m`/`gap`/`px`/`py`/`pt`… all of them | `p={4}` | `padding: 1rem` |
27
- | `width` | sizing — `width`/`height` and the four `min`/`max` props | `width={4}` | `width: 1rem` |
28
- | `borderRadius` | the corner radius, on the spacing scale | `borderRadius={4}` | `border-radius: 1rem` |
29
- | `b` | border width — `b`/`bx`/`by`/`bt`/`bs`… | `b={4}` | `border-width: 4px` |
30
- | `fontSize` | the one that is ÷16 | `fontSize={14}` | `font-size: 0.875rem` |
31
- | `lineHeight` | and `letterSpacing` | `lineHeight={24}` | `line-height: 24px` |
32
- | `strokeWidth` | SVG lengths — and the geometry props `cx`/`cy`/`r`/`x`/`y` | `strokeWidth={4}` | `stroke-width: 4` |
33
- | `animationDuration` | every animation and transition time | `animationDuration={1100}` | `animation-duration: 1100ms` |
24
+ | Prop | Which props | Written | Emitted |
25
+ | ------------------- | ---------------------------------------------------------- | -------------------------- | ---------------------------------------- |
26
+ | `p` | spacing — `p`/`m`/`gap`/`px`/`py`/`pt`… all of them | `p={4}` | `padding: 1rem` |
27
+ | `width` | sizing — `width`/`height` and the four `min`/`max` props | `width={4}` | `width: 1rem` |
28
+ | `borderRadius` | the corner radius, on the spacing scale | `borderRadius={4}` | `border-radius: 1rem` |
29
+ | `b` | border width — `b`/`bx`/`by`/`bt`/`bs`… | `b={4}` | `border-width: 4px` |
30
+ | `fontSize` | the one that is ÷16 | `fontSize={14}` | `font-size: 0.875rem` |
31
+ | `lineHeight` | and `letterSpacing` | `lineHeight={24}` | `line-height: 24px` |
32
+ | `strokeWidth` | SVG lengths — and the geometry props `cx`/`cy`/`r`/`x`/`y` | `strokeWidth={4}` | `--boxStrokeWidth: 4px; stroke-width: 4` |
33
+ | `animationDuration` | every animation and transition time | `animationDuration={1100}` | `animation-duration: 1100ms` |
34
34
 
35
35
  ## Read this next
36
36
 
@@ -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 235, the CSS each writes, one measured example |
43
+ | `node_modules/@box-kite/react/docs/props.md` | one prop exactly: every one, 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.
@@ -87,7 +87,7 @@ Detect the package manager from the lock file rather than asking:
87
87
  7. **Border width and lineHeight are direct px**: `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
88
88
  8. **A gradient fill is a value, not an attribute**: `fill="url(#sky)"`, `stroke="var(--chart-1)"`, `clipPath="url(#frame)"` — so it can be themed and hovered. **A dashboard shape is `components/chart`**: `<Sparkline data={[…]}/>`, `<ProgressRing value={0.6}/>`, `<Gauge>`, `<MiniDonut>` — Box props, no chart library
89
89
  9. **A CSS variable is a prop too**: `vars={{ 'color-revenue': 'sky-500' }}` declares `--color-revenue` on the element and everything inside it — the answer for markup this library does not render. **A third-party chart goes in `<ChartContainer series={['revenue', 'cost']}>`** (`components/chart`), which declares `--chart-1..6` in both themes and one `--color-<series>` per series, so `<Line stroke="var(--color-revenue)"/>` is all the chart ever says about colour. A record names its own paints, as anything `fill` takes: `series={{ done: 'ui-primary', open: 'ui-primary/35' }}` works with a `Box.extend()` token too.
90
- 10. **SVG lengths have no divider and no unit**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset` and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y`. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
90
+ 10. **SVG lengths have no divider**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset`, and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y` write the number as `px`, which is a user unit (`r={20}` → `r: 20px`) — Firefox and Safari drop a bare number there. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
91
91
  11. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`
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)
@@ -117,7 +117,7 @@ Detect the package manager from the lock file rather than asking:
117
117
 
118
118
  28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
119
119
 
120
- 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus into the panel on open (`autoFocus`), and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
120
+ 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
121
121
 
122
122
  30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
123
123
 
@@ -170,3 +170,7 @@ Detect the package manager from the lock file rather than asking:
170
170
  Full reference: `references/` beside this file, and `docs/props.md` inside the package.
171
171
 
172
172
  54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
173
+
174
+ 55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
175
+
176
+ 56. **An SVG shape turns about the viewBox's corner, not its own centre.** Its `transform-box` is `view-box`, so `rotate={45}` alone swings it round `0 0`: write `<G rotate={45} transformBox="fill-box" transformOrigin="center">`. `transformOrigin` takes a keyword (`'top left'`), a fraction or a percentage, a ÷4 number, or positions as CSS writes them (`'48px 48px'` — a px is a user unit). The `transform` attribute stays for a matrix or an angle that is data. Two more props an interactive drawing needs: `pointerEvents="visibleStroke"` makes an edge's stroke its hit area (over a wide transparent one), and `touchAction="none"` on the pressed element keeps a finger's drag from panning the page.
@@ -19,7 +19,7 @@ The recipes, plus the parts of the library that are not props: server rendering,
19
19
  **Config**: `Box.configure({ sink: 'cssom' | 'textContent' | 'string' | 'element', classNames: 'hashed' | 'readable' | 'stable' })` — before the first render.
20
20
  **Floating layers**: `Overlay` (`components/overlay`) renders its children where it is declared, in the browser's **top layer** (`popover="manual"`, shown on mount) — so it beats every stacking context, escapes every clipped and transformed ancestor, and still inherits the theme, the custom properties and the direction around it. `manual` because it owns no dismissal, no ARIA and no open state. `Tooltip`, the `Dropdown` popup and the DataGrid column menu are all this. **Never declare one inside its trigger** — a listbox or a menu inside a `<button>` is unreachable content, and the portal used to hide that by moving it. Where the browser has no Popover API it is portalled into `#box-kite-portal` instead, which is the old behaviour. Reach for it only when you want positioning with no pattern on it; a panel with content in it is `Popover`.
21
21
  **Form controls** (`components/checkbox`, `components/switch`, `components/radioGroup`, `components/radioButton`): real native inputs, so focus, the checked state and form submission come from the platform. `label` is the prop that matters — it renders the text inside a `<label>` wrapping the input, so there is no `htmlFor`/`id` pair and the whole row is clickable; `labelProps` styles that label, every other Box prop styles the control. `<Checkbox label="Accept the terms" name="terms" />`, `<Checkbox label="Select all" indeterminate />` (sets the DOM property **and** `aria-checked="mixed"`), `<Switch label="Email notifications" name="notify" />` (`role="switch"`; Space and Enter both toggle, Enter does not submit the form). `<RadioGroup label="Plan" name="plan" defaultValue="free" onValueChange={(v, { reason }) => …}>` with `<RadioGroup.Item value="free" label="Free" />` children: `role="radiogroup"` named by the label, one shared `name` (generated if omitted), `orientation` `vertical`|`horizontal`, arrows move **and** select with wrapping and disabled items skipped, and Tab enters/leaves the set once — the platform's own tab order is left alone. Style tree `radioGroup` (a `horizontal` variant) > `label`. `RadioButton` alone is a plain radio and still renders on a server. `checked`/`disabled`/`indeterminate` are each a state _and_ a pseudo-class style prop: pass a boolean, or `[state, styles]` for both.
22
- **Popover** (`components/popover`): a panel on the platform's Popover API, and the answer whenever a layer has content a user interacts with. `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`. **No portal**: the panel is in the browser's top layer, so it beats every stacking context and clipped ancestor while keeping the theme, custom properties, direction and tab order it was declared in. Light dismiss (Escape, outside press) and focus return are the browser's; the component adds `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus into the panel (`autoFocus`), the `side`/`align`/`offset`/`flip`/`matchWidth` placement props, and `onOpenChange(open, { reason })` with `trigger`|`escape`|`outside-pointer`|`imperative`. Traps: the trigger must be a **button** (the toggle is the browser's `popovertarget`) and must never carry an `onClick` toggle of your own — light dismiss closes on `pointerdown`, so the `click` after it reopens the panel; the panel is **always rendered** (closed is `display: none`), so the exit is `transitionBehavior="allow-discrete"` and never `<Presence>`, and expensive children want `{open ? … : null}`; and a **close cannot be refused**, since `beforetoggle` is cancelable opening only. Without the Popover API it falls back to a portalled `Overlay` + `useDismiss` + `useFocusReturn`.
22
+ **Popover** (`components/popover`): a panel on the platform's Popover API, and the answer whenever a layer has content a user interacts with. `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`. **No portal**: the panel is in the browser's top layer, so it beats every stacking context and clipped ancestor while keeping the theme, custom properties, direction and tab order it was declared in. Light dismiss (Escape, outside press) and focus return are the browser's; the component adds `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto its first control (`autoFocus`, or `initialFocus` for another), the `side`/`align`/`offset`/`flip`/`matchWidth` placement props, and `onOpenChange(open, { reason })` with `trigger`|`escape`|`outside-pointer`|`imperative`. Traps: the trigger must be a **button** (the toggle is the browser's `popovertarget`) and must never carry an `onClick` toggle of your own — light dismiss closes on `pointerdown`, so the `click` after it reopens the panel; the panel is **always rendered** (closed is `display: none`), so the exit is `transitionBehavior="allow-discrete"` and never `<Presence>`, and expensive children want `{open ? … : null}`; and a **close cannot be refused**, since `beforetoggle` is cancelable opening only. Without the Popover API it falls back to a portalled `Overlay` + `useDismiss` + `useFocusReturn`.
23
23
  **Dialog / AlertDialog** (`components/dialog`): a real `<dialog>` shown with `showModal()`, so **the top layer, the `::backdrop`, an inert page, Escape, focus containment and focus return are the platform's** — no focus trap, no `aria-hidden` sweep, no `z-index`. `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>`. Rendering a `Dialog.Title` writes `aria-labelledby` and a `Dialog.Description` writes `aria-describedby`; neither attribute exists when the part is absent, so a dialog with no visible heading wants `label`/`labelledBy`/`describedBy`. Props: `trigger` (optional — open it from anywhere), `open`/`defaultOpen`/`onOpenChange(open, { reason })` with `trigger`|`escape`|`outside-pointer`|`imperative`, `modal` (default `true`; `false` is `show()` — no backdrop, nothing inert, positioned against its containing block), `dismissible`, `lockScroll` (default: `modal`) and `initialFocus`. `AlertDialog` is a named export of the same module: `role="alertdialog"`, always modal, never dismissed by a press outside, and `initialFocus` on the least destructive action (APG). Traps, all measured: the dialog is **always rendered** (closed is `display: none`), so the exit is a CSS transition and never `<Presence>`; a **close cannot be refused** (`cancel` fires after the fact — use `dismissible={false}`); a press outside is `closedby="any"`, and on a browser without it the press is measured against the dialog's box, because **for a modal dialog the backdrop is the dialog element**; and the platform does not stop the page scrolling, which is what `lockScroll` is for — an `overflow: hidden` class on `<html>`, counted so nested dialogs cannot unlock it early.
24
24
 
25
25
  **Menu** (`components/menu`): APG's menu button on the same Popover API, and a submenu is a popover nested inside it. `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>`. Parts: `Menu.Item`, `Menu.CheckboxItem` (`checked`/`defaultChecked`/`onCheckedChange`), `Menu.RadioGroup` (`value`/`defaultValue`/`onValueChange`) + `Menu.RadioItem value`, `Menu.Group label` (which names the group through `aria-labelledby`), `Menu.Separator`, and `Menu.Sub label`, which renders its own `menuitem` with `aria-haspopup="menu"` and `aria-expanded` plus the menu beside it. Props on the menu: `trigger`, `open`/`defaultOpen`/`onOpenChange(open, { reason })` with `select`|`tab` beside `trigger`|`escape`|`outside-pointer`|`imperative`, `label`/`labelledBy` (left out, the menu takes its trigger's name — APG's rule), and the four placement props `side`/`align`/`offset`/`flip`, which `Menu.Sub` takes too (`side="end"`, `offset={0}` by default, so a submenu abuts its menu). The platform supplies the top layer, light dismiss — Escape closing the innermost menu first — and the toggle; the component supplies the roles, the keys (Down/Up wrapping, Home/End, typeahead where a letter again cycles, Right/Left for a submenu in **reading order**, so they swap in a right-to-left menu) and the focus moves the browser does not make: into the first item on open, and back onto a submenu's own item when it closes. Four things to know: a `disabled` item is `aria-disabled` and **stays focusable**, as APG asks; `Menu.Item` closes the menu on select while a checkbox or radio item does not, and `closeOnSelect` swaps either default; the menu is **always rendered** (closed is `display: none`), so the exit is a CSS transition and never `<Presence>`, and expensive items want gating with `{open ? … : null}`; and it inherits the top layer's one cost, keeping the side it opened on (see `Overlay`). The semantic `<menu>` element is `MenuList` from `components/semantics` now, since two exports named `Menu` are one import away from the wrong component.
@@ -6,7 +6,7 @@ Every prop by category, the six kinds of nesting, and the theme system. Read thi
6
6
 
7
7
  **Spacing**: `p`/`px`/`py`/`pt`/`pr`/`pb`/`pl`/`ps`/`pe`, `m`/`mx`/`my`/`mt`/`mr`/`mb`/`ml`/`ms`/`me`, `gap` — the axis pair is already logical (`px` is `padding-inline`) and `ps`/`pe`, `ms`/`me` are its two sides
8
8
  **Layout**: `display`, `d` (flex-direction), `wrap`, `ai` (align-items), `jc` (justify-content), `justifyItems`/`placeItems`/`placeContent`/`alignContent`/`alignSelf`/`justifySelf`, `flex`/`grow`/`shrink`, `container`/`containerName`/`containerType` (a query container — see `cq`). Every alignment prop also takes the **overflow-safe** forms — `'safe center'`, `'safe start'`, `'safe end'` and the `'unsafe …'` twins: `safe center` centres until the content stops fitting and then aligns to `start`, where plain `center` overflows both edges and the half above the scrollable origin cannot be reached at all. `aspectRatio` is `'square'` (1/1), `'video'` (16/9), a compact ratio (`'4/3'`), a number, or `'auto'`
9
- **Sizing**: `width`/`height` — number (÷4=rem), `'auto'`, `'fit'` (100%), `'fit-screen'` (100vw/vh), fractions (`'1/2'`…), `'33%'`. `minWidth`/`maxWidth`/`minHeight`/`maxHeight` same. All accept `"5%"`.
9
+ **Sizing**: `width`/`height` — number (÷4=rem), `'auto'`, `'fit'` (100%), `'fit-screen'` (100vw/vh), fractions (`'1/2'`…), `'33%'`. `minWidth`/`maxWidth`/`minHeight`/`maxHeight` same. All accept `"5%"`. `height`/`minHeight` (and `fontSize`) also take `'control'` — the size standard in force, `var(--control-height,…)`, 40px by default — and `'control-compact'`/`'control-normal'`/`'control-large'`. Every built-in component is drawn at `compact` (32px), `normal` or `large` (48px) via `variant`, a size inherits, and a project declares its three once with `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on its root.
10
10
  **Colors**: `bgColor`/`color`/`borderColor`/`outlineColor`/`fill`/`stroke` — Tailwind's OKLCH palette, 26 families × 11 steps `'50'`..`'950'` (slate/gray/zinc/neutral/stone/mauve/mist/olive/taupe/red/orange/amber/yellow/lime/green/emerald/teal/cyan/sky/blue/indigo/violet/purple/fuchsia/pink/rose), plus `'white'`/`'black'`/`'transparent'`/`'currentColor'`. **Opacity modifier on any colour value**: `bgColor="blue-500/40"` → `color-mix(in oklab, var(--blue-500) 40%, transparent)` — the mix wraps the _variable_, so it stays themed and shared; unlike `opacity` it fades one declaration, not the element. Works in `vars` and on a `Box.extend()` variable (`"brand/30"`); an unknown token or a percentage outside 0–100 emits nothing. Two more families, `accent` and `base` (copies of `indigo` and `gray`), are what every built-in component is drawn in; `vars={Box.palette({ accent: 'orange', base: 'stone' })}` repoints them for a subtree, by family name or with a ramp of your own
11
11
  **Borders**: `b`/`bx`/`by`/`bt`/`br`/`bb`/`bl`/`bs`/`be` (px — `bs`/`be` are the logical sides), `borderRadius` (÷4) and its nine physical corners plus six logical ones (`borderRadiusStart`/`borderRadiusEnd`, and `borderRadiusStartStart`/`StartEnd`/`EndEnd`/`EndStart` — **block axis first, inline axis second**), `borderStyle`
12
12
  **Text**: `fontSize` (÷16), `fontWeight`, `lineHeight` (px), `textAlign` (`'start'`/`'end'` follow the text direction where `'left'`/`'right'` do not)/`textDecoration`/`textTransform`/`whiteSpace`/`textOverflow`, `textWrap`
@@ -15,9 +15,9 @@ Every prop by category, the six kinds of nesting, and the theme system. Read thi
15
15
 
16
16
  **SVG text**: `textAnchor` (`start`/`middle`/`end` — which part of a label sits on its `x`) and `dominantBaseline` (`alphabetic`/`central`/`hanging`/… — which part sits on its `y`). `textAnchor` is inherited; `dominantBaseline` is not, so like `vectorEffect` its rule targets the element _and_ its descendants and still works on the `<svg>`.
17
17
 
18
- **SVG geometry** (SVG 2, all **user units — no divider**, or a percentage): `cx`/`cy` (`<circle>`, `<ellipse>`), `r` (`<circle>`), `rx`/`ry` (`<ellipse>` radii, `<rect>` corners; also `auto`), `x`/`y` (`<rect>`, `<image>`, `<use>`, `<foreignObject>`, nested `<svg>` — **not** `<text>`). They are real CSS, so they transition: `<Circle r={38} hover={{ r: 40 }} />` is a whole animation. Not inherited — put them on the shape. A `<rect>`'s `width`/`height` are NOT in this family (those prop names are the ÷4 layout scale), which is why `<Rect>` claims them back as its own attributes — `<Rect width={40} height={40} />`. A path's `d` stays an attribute too (no Safari support), and `<Path d="M…" />` is how you write it.
18
+ **SVG geometry** (SVG 2, all **user units — no divider**, written as `px` because Firefox and Safari drop a bare number; or a percentage): `cx`/`cy` (`<circle>`, `<ellipse>`), `r` (`<circle>`), `rx`/`ry` (`<ellipse>` radii, `<rect>` corners; also `auto`), `x`/`y` (`<rect>`, `<image>`, `<use>`, `<foreignObject>`, nested `<svg>` — **not** `<text>`). They are real CSS, so they transition: `<Circle r={38} hover={{ r: 40 }} />` is a whole animation. Not inherited — put them on the shape. A `<rect>`'s `width`/`height` are NOT in this family (those prop names are the ÷4 layout scale), which is why `<Rect>` claims them back as its own attributes — `<Rect width={40} height={40} />`. A path's `d` stays an attribute too (no Safari support), and `<Path d="M…" />` is how you write it.
19
19
 
20
- **SVG elements** (`@box-kite/react/components/svg`, 20 components, server-safe): `Svg`, `G`, `Defs`, `Path`, `Circle`, `Ellipse`, `Rect`, `Line`, `Polyline`, `Polygon`, `SvgText`, `TSpan`, `LinearGradient`, `RadialGradient`, `Stop`, `ClipPath`, `Mask`, `Use`, `SvgSymbol`, `Marker` — so a drawing never writes `tag`. Each one is a Box and takes every prop above. **Each also settles the names SVG and Box both use, for its own element**: `Path`'s `d` is path data, `Rect`'s `width`/`height` are user units, `SvgText`/`TSpan`'s `x`/`y`/`dx`/`dy` are attributes (CSS geometry does not apply to text), `RadialGradient`'s `cx`/`cy`/`r` are attributes (nor to a gradient) — while `Circle`'s `cx` stays CSS and transitions. `transform` is a prop on every shape and group. `Svg` takes `viewBox`/`preserveAspectRatio`/`width`/`height` as attributes (so no ÷4 layout `width` on it) and a `label` prop: no label means `aria-hidden`, a label means `role="img"` with that name. A paint server is a value, not an attribute: `fill="url(#sky)"`, `clipPath="url(#frame)"` — themed and hoverable like any other paint. A themed gradient stop is `<Stop stopColor="currentColor" color="amber-300" />`. `BaseSvg` is deprecated — it is `Svg` with a 24×24 preset.
20
+ **SVG elements** (`@box-kite/react/components/svg`, 20 components, server-safe): `Svg`, `G`, `Defs`, `Path`, `Circle`, `Ellipse`, `Rect`, `Line`, `Polyline`, `Polygon`, `SvgText`, `TSpan`, `LinearGradient`, `RadialGradient`, `Stop`, `ClipPath`, `Mask`, `Use`, `SvgSymbol`, `Marker` — so a drawing never writes `tag`. Each one is a Box and takes every prop above. **Each also settles the names SVG and Box both use, for its own element**: `Path`'s `d` is path data, `Rect`'s `width`/`height` are user units, `SvgText`/`TSpan`'s `x`/`y`/`dx`/`dy` are attributes (CSS geometry does not apply to text), `RadialGradient`'s `cx`/`cy`/`r` are attributes (nor to a gradient) — while `Circle`'s `cx` stays CSS and transitions. `transform` is a prop on every shape and group, for a matrix or an angle that is data; a fixed turn about a shape's own centre is `rotate={45} transformBox="fill-box" transformOrigin="center"` (an SVG shape's `transform-box` is `view-box`, so `rotate` alone swings it round the viewBox's corner). `pointerEvents="visibleStroke"` makes the stroke the hit area, `touchAction="none"` keeps a finger's drag from panning the page, `paintOrder` takes up to three keywords, and `strokeDasharray="dashed"`/`"dotted"` scale with `strokeWidth`. `Svg` takes `viewBox`/`preserveAspectRatio`/`width`/`height` as attributes (so no ÷4 layout `width` on it) and a `label` prop: no label means `aria-hidden`, a label means `role="img"` with that name. A paint server is a value, not an attribute: `fill="url(#sky)"`, `clipPath="url(#frame)"` — themed and hoverable like any other paint. A themed gradient stop is `<Stop stopColor="currentColor" color="amber-300" />`. `BaseSvg` is deprecated — it is `Svg` with a 24×24 preset.
21
21
 
22
22
  **Icons** (`@box-kite/react/components/icon`, server-safe): `<Icon size={5} color="amber-500" label="Sunny"><Sun /></Icon>` — Box props on an icon somebody else drew, wrapping exactly one element from lucide, Tabler, react-icons, or a raw `<svg>`. It resolves the props to a class and puts that on the icon's own `<svg>`, so it knows no set's API: `size` is the ÷4 scale (`size={6}` is 24px, the default; an icon set's own `size` counts in pixels, so `size={20}` becomes `size={5}`) and it lands in the _class_, where a CSS declaration outranks the `width`/`height` attributes the set writes for itself. `strokeWidth` is likewise the ordinary Box prop, so it can change on hover or at a breakpoint. No `label` means `aria-hidden`, a `label` means `role="img"` — the same rule `Svg` follows. **Prefer `Svg` over wrapping one in `Icon`**: `Svg` takes these props directly. (Wrapping works — `Icon` asks the child which convention it follows and routes `role`/`aria-label` into `props` for a component of ours — but it is a layer nobody needs.) The hook underneath is public — `useClassNames(props)` from the main entry returns `{ className, styles }` for anything Box cannot render (a `motion.div`, a `NavLink`); render `styles` beside the element (it is defined in element mode only). **Beyond lucide**: Iconify's 300k+ icons in 200+ sets reach the same `<Icon>`, the choice being only when the icon becomes markup — paste one icon's SVG (no dependency, server-renders); `unplugin-icons` for a set at build time (`npm i -D unplugin-icons @iconify-json/<set> @svgr/core @svgr/plugin-jsx`, plugin with `{ compiler: 'jsx', jsx: 'react' }`, `/// <reference types="unplugin-icons/types/react" />` in a `.d.ts`, then `import SiGithub from '~icons/simple-icons/github'`); or `@iconify/react` when the name is data — it fetches in the browser, so it is a client component and the server sends no icon. Turbopack runs no unplugin: under Next.js 16 the build-time recipe needs `next build --webpack`.
23
23
 
@@ -1,5 +1,5 @@
1
1
  ---
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.
2
+ description: @box-kite/react (Box Kite): the runtime CSS-in-JS library whose Box component takes CSS properties as typed 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
  ---
@@ -22,7 +22,7 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
22
22
  7. **Border width and lineHeight are direct px**: `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
23
23
  8. **A gradient fill is a value, not an attribute**: `fill="url(#sky)"`, `stroke="var(--chart-1)"`, `clipPath="url(#frame)"` — so it can be themed and hovered. **A dashboard shape is `components/chart`**: `<Sparkline data={[…]}/>`, `<ProgressRing value={0.6}/>`, `<Gauge>`, `<MiniDonut>` — Box props, no chart library
24
24
  9. **A CSS variable is a prop too**: `vars={{ 'color-revenue': 'sky-500' }}` declares `--color-revenue` on the element and everything inside it — the answer for markup this library does not render. **A third-party chart goes in `<ChartContainer series={['revenue', 'cost']}>`** (`components/chart`), which declares `--chart-1..6` in both themes and one `--color-<series>` per series, so `<Line stroke="var(--color-revenue)"/>` is all the chart ever says about colour. A record names its own paints, as anything `fill` takes: `series={{ done: 'ui-primary', open: 'ui-primary/35' }}` works with a `Box.extend()` token too.
25
- 10. **SVG lengths have no divider and no unit**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset` and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y`. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
25
+ 10. **SVG lengths have no divider**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset`, and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y` write the number as `px`, which is a user unit (`r={20}` → `r: 20px`) — Firefox and Safari drop a bare number there. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
26
26
  11. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`
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)
@@ -52,7 +52,7 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
52
52
 
53
53
  28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
54
54
 
55
- 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus into the panel on open (`autoFocus`), and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
55
+ 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
56
56
 
57
57
  30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
58
58
 
@@ -105,3 +105,7 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
105
105
  Full reference: `node_modules/@box-kite/react/docs/props.md` in the project, or https://www.box-kite.dev/props.md — every prop, the CSS it writes and one measured example.
106
106
 
107
107
  54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
108
+
109
+ 55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
110
+
111
+ 56. **An SVG shape turns about the viewBox's corner, not its own centre.** Its `transform-box` is `view-box`, so `rotate={45}` alone swings it round `0 0`: write `<G rotate={45} transformBox="fill-box" transformOrigin="center">`. `transformOrigin` takes a keyword (`'top left'`), a fraction or a percentage, a ÷4 number, or positions as CSS writes them (`'48px 48px'` — a px is a user unit). The `transform` attribute stays for a matrix or an angle that is data. Two more props an interactive drawing needs: `pointerEvents="visibleStroke"` makes an edge's stroke its hit area (over a wide transparent one), and `touchAction="none"` on the pressed element keeps a finger's drag from panning the page.
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 235 props themselves.
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 238 props themselves.
4
4
 
5
5
  ## Where the files are
6
6
 
@@ -54,7 +54,7 @@ Every value there is a prop, and every prop value becomes one class the engine i
54
54
  7. **Border width and lineHeight are direct px**: `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
55
55
  8. **A gradient fill is a value, not an attribute**: `fill="url(#sky)"`, `stroke="var(--chart-1)"`, `clipPath="url(#frame)"` — so it can be themed and hovered. **A dashboard shape is `components/chart`**: `<Sparkline data={[…]}/>`, `<ProgressRing value={0.6}/>`, `<Gauge>`, `<MiniDonut>` — Box props, no chart library
56
56
  9. **A CSS variable is a prop too**: `vars={{ 'color-revenue': 'sky-500' }}` declares `--color-revenue` on the element and everything inside it — the answer for markup this library does not render. **A third-party chart goes in `<ChartContainer series={['revenue', 'cost']}>`** (`components/chart`), which declares `--chart-1..6` in both themes and one `--color-<series>` per series, so `<Line stroke="var(--color-revenue)"/>` is all the chart ever says about colour. A record names its own paints, as anything `fill` takes: `series={{ done: 'ui-primary', open: 'ui-primary/35' }}` works with a `Box.extend()` token too.
57
- 10. **SVG lengths have no divider and no unit**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset` and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y`. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
57
+ 10. **SVG lengths have no divider**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset`, and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y` write the number as `px`, which is a user unit (`r={20}` → `r: 20px`) — Firefox and Safari drop a bare number there. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
58
58
  11. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`
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)
@@ -84,7 +84,7 @@ Every value there is a prop, and every prop value becomes one class the engine i
84
84
 
85
85
  28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
86
86
 
87
- 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus into the panel on open (`autoFocus`), and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
87
+ 29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
88
88
 
89
89
  30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
90
90
 
@@ -137,3 +137,7 @@ Every value there is a prop, and every prop value becomes one class the engine i
137
137
  Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.
138
138
 
139
139
  54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
140
+
141
+ 55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
142
+
143
+ 56. **An SVG shape turns about the viewBox's corner, not its own centre.** Its `transform-box` is `view-box`, so `rotate={45}` alone swings it round `0 0`: write `<G rotate={45} transformBox="fill-box" transformOrigin="center">`. `transformOrigin` takes a keyword (`'top left'`), a fraction or a percentage, a ÷4 number, or positions as CSS writes them (`'48px 48px'` — a px is a user unit). The `transform` attribute stays for a matrix or an angle that is data. Two more props an interactive drawing needs: `pointerEvents="visibleStroke"` makes an edge's stroke its hit area (over a wide transparent one), and `touchAction="none"` on the pressed element keeps a finger's drag from panning the page.