@box-kite/react 2.2.6 → 2.2.8

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 (122) hide show
  1. package/.claude/rules/box-kite-rules.md +2 -0
  2. package/.claude/skills/box-kite/SKILL.md +2 -0
  3. package/.claude/skills/box-kite/references/extending.md +10 -0
  4. package/.claude/skills/box-kite/references/styling.md +3 -2
  5. package/.cursor/rules/box-kite.mdc +2 -0
  6. package/AGENTS.md +2 -0
  7. package/BOX_AI_CONTEXT.md +128 -2
  8. package/BOX_KITE_AI_CONTEXT.md +128 -2
  9. package/analytics.cjs +1 -0
  10. package/analytics.d.ts +10 -0
  11. package/analytics.mjs +121 -0
  12. package/behavior.cjs +1 -1
  13. package/behavior.mjs +42 -38
  14. package/box.cjs +1 -1
  15. package/box.d.ts +14 -0
  16. package/box.mjs +17 -16
  17. package/catalog.cjs +1 -1
  18. package/catalog.mjs +8 -0
  19. package/client.cjs +1 -1
  20. package/client.mjs +144 -113
  21. package/components/accordion.cjs +1 -1
  22. package/components/accordion.mjs +19 -10
  23. package/components/agent.cjs +1 -1
  24. package/components/agent.mjs +21 -8
  25. package/components/calendar.cjs +1 -1
  26. package/components/calendar.d.ts +3 -1
  27. package/components/calendar.mjs +154 -142
  28. package/components/combobox.cjs +1 -1
  29. package/components/combobox.mjs +184 -174
  30. package/components/dashboard.cjs +1 -1
  31. package/components/dashboard.mjs +183 -170
  32. package/components/dataGrid/models/columnModel.d.ts +1 -0
  33. package/components/dataGrid/models/gridModel.d.ts +18 -0
  34. package/components/dataGrid/models/headerCellModel.d.ts +1 -0
  35. package/components/dataGrid/models/rangeModel.d.ts +3 -0
  36. package/components/dataGrid.cjs +3 -3
  37. package/components/dataGrid.mjs +1015 -923
  38. package/components/dateField.cjs +1 -1
  39. package/components/dateField.mjs +165 -159
  40. package/components/datePicker.cjs +1 -1
  41. package/components/datePicker.mjs +198 -183
  42. package/components/dialog.cjs +1 -1
  43. package/components/dialog.mjs +38 -33
  44. package/components/dropdown.cjs +1 -1
  45. package/components/dropdown.mjs +107 -93
  46. package/components/form.cjs +1 -1
  47. package/components/form.mjs +22 -18
  48. package/components/menu.cjs +1 -1
  49. package/components/menu.d.ts +2 -0
  50. package/components/menu.mjs +196 -175
  51. package/components/overlay.cjs +1 -1
  52. package/components/overlay.mjs +7 -7
  53. package/components/popover.cjs +1 -1
  54. package/components/popover.mjs +4 -0
  55. package/components/radioGroup.cjs +1 -1
  56. package/components/radioGroup.mjs +10 -5
  57. package/components/semantics.d.ts +29 -29
  58. package/components/slider.cjs +1 -1
  59. package/components/slider.mjs +84 -83
  60. package/components/sortableList.cjs +1 -1
  61. package/components/sortableList.mjs +158 -150
  62. package/components/swipeItem.cjs +1 -1
  63. package/components/swipeItem.mjs +92 -84
  64. package/components/tabs.cjs +1 -1
  65. package/components/tabs.mjs +10 -5
  66. package/components/timeZoneSelect.cjs +1 -1
  67. package/components/timeZoneSelect.mjs +40 -35
  68. package/components/toaster.cjs +1 -1
  69. package/components/toaster.mjs +151 -113
  70. package/create/src/index.d.ts +1 -0
  71. package/create/src/options.d.ts +27 -0
  72. package/create/src/options.test.d.ts +1 -0
  73. package/create/src/packageManager.d.ts +5 -0
  74. package/create/src/project.d.ts +25 -0
  75. package/create/src/project.test.d.ts +1 -0
  76. package/create/templates/ts/designs/azure.d.ts +5 -0
  77. package/create/templates/ts/designs/ember.d.ts +5 -0
  78. package/create/templates/ts/designs/mono.d.ts +5 -0
  79. package/create/templates/ts/src/App.d.ts +2 -0
  80. package/create/templates/ts/src/design.d.ts +5 -0
  81. package/create/templates/ts/src/main.d.ts +0 -0
  82. package/dataGridDateParts.cjs +1 -1
  83. package/dataGridDateParts.mjs +56 -55
  84. package/dataGridList.cjs +1 -1
  85. package/dataGridList.mjs +437 -436
  86. package/docs/props.md +19 -19
  87. package/effects.cjs +1 -1
  88. package/effects.mjs +1 -1
  89. package/interactionListening.cjs +1 -0
  90. package/interactionListening.mjs +117 -0
  91. package/interactions.cjs +1 -0
  92. package/interactions.mjs +25 -0
  93. package/layer.cjs +1 -1
  94. package/layer.mjs +68 -66
  95. package/package.json +139 -70
  96. package/placement.cjs +1 -1
  97. package/placement.mjs +10 -10
  98. package/platform.cjs +1 -1
  99. package/platform.mjs +44 -25
  100. package/react/interactions/interactions.d.ts +30 -0
  101. package/react/popover/usePopoverLayer.d.ts +3 -0
  102. package/react/theme/theme.d.ts +9 -0
  103. package/react/theme/themeContext.d.ts +2 -0
  104. package/react/useStyles.d.ts +8 -1
  105. package/react-shared.cjs +1 -1
  106. package/react-shared.mjs +49 -48
  107. package/reporting.cjs +1 -0
  108. package/reporting.mjs +51 -0
  109. package/rsc.cjs +1 -1
  110. package/rsc.d.ts +3 -0
  111. package/rsc.mjs +1 -1
  112. package/scrolling.cjs +1 -1
  113. package/scrolling.mjs +1 -1
  114. package/supabase/functions/collect/index.d.ts +30 -0
  115. package/supabase/functions/collect/index.test.d.ts +1 -0
  116. package/utils/analytics/analytics.d.ts +72 -0
  117. package/utils/analytics/analyticsIdentity.d.ts +13 -0
  118. package/utils/interactions/componentAttribute.d.ts +6 -0
  119. package/utils/interactions/interactionChannel.d.ts +35 -0
  120. package/utils/interactions/interactionJson.d.ts +6 -0
  121. package/utils/interactions/interactionListening.d.ts +6 -0
  122. package/utils/interactions/interactionTypes.d.ts +224 -0
@@ -95,3 +95,5 @@ globs: '**/*.{ts,tsx,jsx}'
95
95
  53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
96
96
 
97
97
  Full reference: `src/BOX_KITE_AI_CONTEXT.md` or invoke `/box-kite` skill.
98
+
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`.
@@ -168,3 +168,5 @@ Detect the package manager from the lock file rather than asking:
168
168
  53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
169
169
 
170
170
  Full reference: `references/` beside this file, and `docs/props.md` inside the package.
171
+
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`.
@@ -16,9 +16,19 @@ export const components = Box.components({
16
16
  children: { header: { styles: { fontSize: 18, fontWeight: 600 } } },
17
17
  },
18
18
  subgrid: { extends: 'datagrid', styles: { b: 0, shadow: 'none' } },
19
+ // A part extends too: a component by name, or another part by its dotted path.
20
+ picker: { children: { trigger: { extends: 'button', styles: { px: 3 } } } },
19
21
  });
20
22
  ```
21
23
 
24
+ **A second design, chosen in CSS**: `Box.components(styles, { design: 'ember' })` merges `styles` over the app's components
25
+ and applies them under `data-design="ember"` — `<Box.Theme design="classic" designStorageKey="design">` writes the
26
+ attribute, `Box.useDesign()` switches it, and a pre-paint script that copies the stored value onto `<html>` means no flash
27
+ on a prerendered page. Each design rule is one class more specific than the app's, so every state is outranked; a design
28
+ cannot delete a key, so it writes `none` (`shadow: 'none'`, `bgGradient: 'none'`). Its colours are `Box.palette()` in a
29
+ root `dataAttr={{ 'design=ember': { vars: … } }}`, since every built-in component is drawn in `accent` and `base`. A
30
+ subtree whose design React decides is still `Box.createComponents()` + `<Box.Components value>`.
31
+
22
32
  ## Extension System
23
33
 
24
34
  ```tsx
@@ -7,7 +7,7 @@ Every prop by category, the six kinds of nesting, and the theme system. Read thi
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
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%"`.
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
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`
13
13
  **Position**: `position`, `top`/`right`/`bottom`/`left`/`inset`/`insetX`/`insetY`/`insetStart`/`insetEnd`, `zIndex` — number (÷4), `'auto'`, fractions (`'1/2'`, `'-1/3'`) or a percentage; `insetX`/`insetY` are `inset-inline`/`inset-block`, the way `mx`/`my` are `margin-inline`/`margin-block`, and `insetStart`/`insetEnd` are that axis's two sides
@@ -121,7 +121,8 @@ minimum**. `interpolate` names the space: `'srgb'`, `'hsl'`, `'oklab'`, `'oklch'
121
121
  `'oklch'` is what keeps two stops out of the grey middle sRGB drags them through, and a `-longer` hue turns two stops
122
122
  into a spectrum. The record is judged **whole**: one unknown stop, one misspelt key (`interpolat`), two kinds at once,
123
123
  and the value emits no rule and no class name. It writes `background-image`, so `bgGradient` and `bgImage` are the
124
- same property — use one.
124
+ same property — use one; `bgGradient: 'none'` is how a variant or a design takes a gradient off. `letterSpacing` is px as a
125
+ number and Tailwind's tracking names in `em` as a string (`'tighter'`…`'widest'`).
125
126
 
126
127
  **Shadows stack, four at a time**: `box-shadow` is one property, so each layer sets its own custom property and all
127
128
  four write the same composed declaration — a ring and an elevation coexist instead of the last rule winning.
@@ -103,3 +103,5 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
103
103
  53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
104
104
 
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
+
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`.
package/AGENTS.md CHANGED
@@ -135,3 +135,5 @@ Every value there is a prop, and every prop value becomes one class the engine i
135
135
  53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
136
136
 
137
137
  Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.
138
+
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`.
package/BOX_AI_CONTEXT.md CHANGED
@@ -307,6 +307,19 @@ Any colour value takes a slash and a percentage:
307
307
  - A token the palette does not have, or a percentage outside 0–100, produces **no rule and no class
308
308
  name** — the same silence every unmatched value gets.
309
309
 
310
+ **Two more families, `accent` and `base`, are what every built-in component is drawn in.** They start as
311
+ copies of `indigo` and `gray`, so nothing changes until one is pointed somewhere. `Box.palette()` returns the
312
+ `vars` record that does it, for an element and its subtree — a family by name, or a ramp of your own:
313
+
314
+ ```tsx
315
+ <Box vars={Box.palette({ accent: 'orange', base: 'stone' })} /> // every Button inside is orange
316
+ <Box vars={Box.palette({ accent: { 500: 'oklch(68% .19 42)' } })} /> // a step left out keeps its default
317
+ <Box theme={{ dark: { vars: Box.palette({ accent: 'amber' }) } }} /> // nests like any vars
318
+ ```
319
+
320
+ Write `accent-*`/`base-*` in your own components when they should follow a design, and a named family when the
321
+ colour is the content (a status, a chart series). A class built from them reads `bgColor-accent-500`.
322
+
310
323
  ### Gradients, shadows and rings
311
324
 
312
325
  A gradient is a **value**, written as a record — so its stops are palette tokens, it takes the opacity
@@ -330,7 +343,10 @@ modifier, it is themed, and two elements asking for the same one share a class.
330
343
  - A stop is any colour value — a token, `blue-500/40`, a system colour, `var(--chart-1)` — or a
331
344
  `[colour, position]` pair. **Two stops minimum**, and the record is judged whole: one bad stop, one
332
345
  unknown key, two kinds at once, and the whole value emits no rule and no class name.
333
- - It writes `background-image`, so `bgGradient` and `bgImage` are the same property.
346
+ - It writes `background-image`, so `bgGradient` and `bgImage` are the same property. `bgGradient` is declared
347
+ later and wins, so a variant or a design takes a gradient off with `bgGradient: 'none'`, never `bgImage: 'none'`.
348
+ - `letterSpacing` is pixels as a number, and Tailwind's tracking names in `em` — `'tighter'` (-0.05em), `'tight'`,
349
+ `'normal'`, `'wide'`, `'wider'`, `'widest'` (0.1em) — which is what a display heading wants at every size.
334
350
 
335
351
  **Four shadows stack**, because each sets its own custom property and all four write one composed
336
352
  `box-shadow`. A ring and an elevation coexist instead of the last one winning:
@@ -1094,6 +1110,40 @@ const harbor = Box.createComponents({ button: { styles: { borderRadius: 0 } } },
1094
1110
  - **A Server Component cannot read the provider.** A client component under it can.
1095
1111
  - **Types for two designs that both name a component**: `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}` (from `@box-kite/core/types`). Extending two `ExtractComponentsAndVariants` that disagree on `button` is an interface conflict.
1096
1112
 
1113
+ ### Box.components(styles, { design }) — A Design Chosen in CSS
1114
+
1115
+ A registry is chosen by React context, so a prerendered page can only switch to it after hydration — a flash, and a
1116
+ hydration mismatch if the client renders it first. A **named design** is chosen by an attribute instead: its styles are
1117
+ merged over the app's components and applied wherever an ancestor carries `data-design="<name>"`, so a stored choice
1118
+ written onto `<html>` before the first paint is the whole fix.
1119
+
1120
+ ```tsx
1121
+ Box.components({ button: { styles: { borderRadius: 2.5 } }, dialog: { styles: { borderRadius: 5, p: 7 } } }, { design: 'ember' });
1122
+
1123
+ <Box.Theme
1124
+ use="global"
1125
+ design="classic"
1126
+ designStorageKey="design"
1127
+ globalStyles={{ dataAttr: { 'design=ember': { vars: Box.palette({ accent: 'orange', base: 'stone' }) } } }}
1128
+ >
1129
+ <App />
1130
+ </Box.Theme>;
1131
+
1132
+ const [design, setDesign] = Box.useDesign(); // setDesign(null) goes back to the design prop
1133
+ ```
1134
+
1135
+ - **Both designs are in the CSS.** A Box whose component a design changes carries the design's whole class list beside its
1136
+ own, each rule one class more specific — so every state the app's design styles (a theme, a hover, a variant) is
1137
+ outranked by the same state of the design. A component the design leaves alone costs nothing.
1138
+ - **To take something back off, write its `none`** (`shadow: 'none'`, `bgGradient: 'none'`): a design merges, it cannot delete a key.
1139
+ - **A design nested in another stops the outer one** — a `data-design="classic"` preview inside an Ember page is Classic.
1140
+ The palette is `vars`, which inherit, so that wrapper declares its own `Box.palette()` too.
1141
+ - **Props still win**, and a Box under `<Box.Components value>` resolves against that registry and no named design.
1142
+ - `Box.Theme` writes `data-design` on `<html>` (`use="global"`) or on its wrapper, restores `designStorageKey` after
1143
+ hydration without writing the default over a value a pre-paint script set, and pauses transitions for the switch.
1144
+ `applyDesignToElement` is the framework-free half, in `@box-kite/core`.
1145
+ - **A part can `extends` too**: `trigger: { extends: 'button' }` or `'dialog.title'`. It used to be silently ignored.
1146
+
1097
1147
  ---
1098
1148
 
1099
1149
  ## Extension System
@@ -2564,7 +2614,7 @@ function TrendCell({ cell }: { cell: CellModel<Row> }) {
2564
2614
  4. **fontSize divider is 16** (not 4). `fontSize={14}` → 14px
2565
2615
  5. **Spacing divider is 4**. `p={4}` → 16px (1rem)
2566
2616
  6. **Border width and lineHeight are direct px**. `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
2567
- 7. **Colors are Tailwind's OKLCH palette**: `'gray-500'`, `'blue-600'` — 26 families × 11 steps, and an opacity modifier on any of them: `bgColor="blue-500/40"` (a `color-mix`, not `opacity`)
2617
+ 7. **Colors are Tailwind's OKLCH palette**: `'gray-500'`, `'blue-600'` — 26 families × 11 steps, and an opacity modifier on any of them: `bgColor="blue-500/40"` (a `color-mix`, not `opacity`). The built-in components are drawn in two more, `accent` and `base`, which `Box.palette()` repoints
2568
2618
  8. **Breakpoints are mobile-first**: base → sm → md → lg → xl → xxl
2569
2619
  9. **Theme styles nest**: `theme={{ dark: { hover: { ... } } }}`
2570
2620
  10. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`. `data-*` and `aria-*` go there too — a `data-state` written at the top level typechecks and is then dropped
@@ -3886,3 +3936,79 @@ call being shown and `renderAndWaitForResponse` is a question whose `respond` is
3886
3936
  `onDecisionChange` calls. json-render is the same `z.fromJSONSchema` over `catalog()` itself.
3887
3937
 
3888
3938
  Full reference: `docs/interop.md`.
3939
+
3940
+ ## Interaction events (`Box.onInteraction`)
3941
+
3942
+ One listener hears every interaction, in one typed stream, so an analytics call, an audit log or a session recorder is one function:
3943
+
3944
+ ```tsx
3945
+ const stop = Box.onInteraction((event) => {
3946
+ analytics.track(`${event.component}.${event.type}`, {
3947
+ kind: event.kind,
3948
+ source: event.source,
3949
+ reason: event.reason,
3950
+ value: event.value,
3951
+ at: event.time,
3952
+ });
3953
+ });
3954
+ ```
3955
+
3956
+ - **`kind`** is who reported it. `'change'` is a component's own change: `type` is the callback it mirrors without its `on`, `reason` is the word that callback is given (`'escape'`, `'outside-pointer'`, `'keyboard'`…), `value` the new value and `details` the rest of what the callback is told. `'dom'` is a `click` or a `change` on anything a component styled, named by the nearest `data-bk` (below). `'custom'` is the app's own, from `Box.reportInteraction`.
3957
+ - **The stream is typed**: narrow on `kind`, then `component` and `type`, and `value` is what that change carries — `if (e.kind === 'change' && e.component === 'datagrid' && e.type === 'sortingChange') e.value?.columnKey`. The whole map is `ComponentInteractions`, exported from `@box-kite/react` with `InteractionEvent`.
3958
+ - **`value` and `details` are plain JSON copies**, so an event goes to a log or a beacon as it stands: a `Date` is its ISO string, a `Set` an array, and a function, a React element, a DOM node or a class instance is left out.
3959
+ - **What a person typed is content, and a listener asks for it.** A DataGrid search, filter values, a cell's value before and after an edit (and a refusal's message), the cells a paste wrote, a date picked or typed, the rows a `Combobox` selected and a toast's text are left out of what a listener is handed, and `url` has no query string and no hash. `Box.onInteraction(listener, { content: true })` hands all of it over. A report names its content in `content: ['value', 'oldValue']` (the value and any `details` keys), which is how `Box.reportInteraction` marks an app's own.
3960
+ - **`source`** is `'app'` when code did it — the reasons `imperative`, `programmatic` and `timeout`, a `toast()` call, an untrusted DOM event — and `'user'` otherwise.
3961
+ - **`event` is the DOM event behind it** — passed by the component, or the one the browser was dispatching when the change happened, so a DataGrid sorted by a header press carries that press. **`target`** is the control that handled it (the tab, not the icon inside it), else the component's own element. `timeStamp` is `performance.now()`, `time` is `Date.now()` and `url` is the page it happened on, so an event delivered after a navigation still names its own.
3962
+ - **It fires whether or not the app passed the callback, and it is fire and forget.** An event is queued as it happens and delivered in order, batched, in a **background task** after the handler that caused it (`scheduler.postTask` at `background` priority, `setTimeout` where there is none), so a slow listener cannot delay the click, the render or the paint it reports, and a listener that throws goes to `reportError` while the rest still run. `pagehide` delivers whatever is still queued, and **`Box.flushInteractions()`** delivers it now, which is what an analytics sender calls before it sends its last batch, and what a test calls before it asserts.
3963
+ - **With nobody listening, a report costs one comparison** and nothing is kept, so subscribe in the module that boots the app: what happens before the first listener is gone. The delivery itself (the batching, the JSON copies, the press listeners) is a chunk of its own, fetched with the first subscription; anything reported while it is on its way waits for it.
3964
+ - **One choice is one event, from the component the app rendered.** A component built from others keeps its parts quiet: a day picked in a `DatePicker` is `datePicker.valueChange` and `datePicker.openChange` (`select`), never `calendar.valueChange` as well. The same holds for the `Combobox` in a `TimeZoneSelect` and for the DataGrid's own filters, editors and panels, whose changes the grid reports itself — while a column's `Cell` and a `rowDetail`, which are the app's, report as usual wherever the grid renders them.
3965
+ - **On a server nothing listens** — no person is interacting there, and a subscription there is a no-op — and the channel lives on `globalThis` under `Symbol.for('box-kite.interactions@2')`, so two copies of the package share it.
3966
+
3967
+ | `component` | `type` (and `reason`) |
3968
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
3969
+ | `dialog`, `alertDialog`, `popover`, `menu`, `datePicker`, `combobox`, `dropdown`, `timeZoneSelect`, `collapsible`, `toolCall`, `reasoning`, `swipeItem` | `openChange` · `menu.sub` `openChange` only when the keyboard opens it, with `details.item` |
3970
+ | `tabs`, `accordion`, `radioGroup`, `dropdown`, `combobox`, `calendar`, `dateField`, `datePicker`, `dateRangePicker`, `timeZoneSelect`, `menu.radioGroup` | `valueChange` |
3971
+ | `menu.item` | `select` — the value is the item's `value` prop, else its text · `menu.checkboxItem` `checkedChange`, with `details.item` · `approval` `decisionChange` |
3972
+ | `calendar` | `pageChange` (`next`/`previous`), `viewChange` (`zoom-in`/`zoom-out`) |
3973
+ | `slider` | `valueCommit` · `dashboard` `layoutCommit`, `grab`, `grabCancel` · `widget` `refresh` (`refresh`/`retry`) · `sortableList` `orderChange`, `orderCommit`, `grab`, `grabCancel` · `swipeItem` `fullSwipe` |
3974
+ | `form` | `submit` — the value is the **field names**, never what was typed into them |
3975
+ | `toaster` | `show` (reason: the kind), `update`, `dismiss` (`timeout`/`close`/`action`/`imperative`), `action` (an action that leaves the toast up) |
3976
+ | `datagrid` | `sortingChange`, `globalFilterChange`, `columnFiltersChange`, `paginationChange`, `selectedRowKeysChange`, `expandedRowKeysChange`, `expandedTreeKeysChange`, `rangeChange` (a marked block only), `copy`, `cellEditStart`, `cellEdit`, `cellEditCancel`, `cellEditReject`, `paste`, `columnPinChange`, `columnVisibilityChange`, `columnResize`, `groupingChange`, `groupExpandedChange`, `selectionViewChange`, `export`, `retry` — and on a phone `recordChange`, `sheetChange`, `selectModeChange`, `showMore` |
3977
+
3978
+ **Nothing reports per frame, per hover or per keystroke.** A drag reports where it ends (`valueCommit`, `layoutCommit`, `columnResize`, a range block), so a slider's `valueChange` and a dashboard's `layoutChange` are not events here; neither is a tooltip shown on hover, a submenu opened by the pointer, the text typed into a combobox or a dropdown's search, focus moving in a calendar or a grid, nor a page reset that a sort or a filter caused. A change the app makes through a ref (`exportCsv()`, `clearEdits()`, `refresh()`) does not report; one that goes through a component's state (closing a `Dialog` from code) reports with `source: 'app'`.
3979
+
3980
+ **A press is a `dom` event, and it needs `data-bk`.** `Box.configure({ componentAttribute: true })` writes `data-bk="<component key>"` on every element a component styles, user components from `Box.components()` included, and the channel names a `click` or a `change` by the nearest one — on the control it hit, with `details.interactive` saying whether a control was hit at all, so a dead click is countable. A press that changed something arrives as that change, never twice: `tabs.valueChange` carries the click as its `event` and the tab as its `target`, and so does an app's own `Box.reportInteraction` made inside the handler. Only a press nothing explained — a plain `Button`, a dead click — is a `dom` event. Without the attribute nothing can be named, and development says so once in the console.
3981
+
3982
+ ```tsx
3983
+ Box.configure({ componentAttribute: true }); // in a module both the server and the browser run
3984
+ ```
3985
+
3986
+ An app's own `data-bk` in `props` wins. Set the flag before the first render, on both sides, or a hydrating browser sees attributes the server never wrote. The half of the channel that delivers and watches the page is a chunk of under 1.5 KB, fetched with the first subscription; in a test, `await vi.dynamicImportSettled()` after subscribing, before the first assertion.
3987
+
3988
+ **A moment of the app's own is `Box.reportInteraction`.** Box never wraps the handlers an app passes in `props`: wrapping them all would cost every render, report a hover or a scroll per frame, and name nothing better than `button`. So a hover, a section scrolled into view or a wizard step is reported where it happens, in the same shape, to the same listeners, fire and forget:
3989
+
3990
+ ```tsx
3991
+ <Button props={{ onMouseEnter: (event) => Box.reportInteraction({ component: 'pricing-cta', type: 'mouseEnter', event }) }}>Pricing</Button>
3992
+ ```
3993
+
3994
+ It is a plain function, not a hook, so it works in an effect, a fetch callback or a module with no React in it. `event` may be React's synthetic one; `target`, `source`, `timeStamp`, `time` and `url` are filled in. Keep it to discrete moments, the rule the components follow.
3995
+
3996
+ ### Sending it somewhere: `@box-kite/react/analytics`
3997
+
3998
+ `createAnalytics({ send })` is that stream collected: every interaction as a flat JSON record with a session id, batched, held until consent is granted, and sent as the page is hidden. No React and no engine, 1.9 KB gzipped.
3999
+
4000
+ ```ts
4001
+ import { beacon, consoleLog, createAnalytics } from '@box-kite/react/analytics';
4002
+
4003
+ const analytics = createAnalytics({
4004
+ send: [beacon('https://collect.example.com/events'), consoleLog()],
4005
+ consent: 'pending', // nothing stored or sent until the reader answers
4006
+ });
4007
+ analytics.consent('granted');
4008
+ ```
4009
+
4010
+ - **A record** is `{ id, name, kind, component, type, reason, value, details, source, trigger, label, time, url, sessionId, visitorId? }`: `name` is `component.type`, `trigger` the DOM event's type, `label` the words on a pressed button, link, tab or menu item (never a cell's or a field's without `content`), `time` ISO 8601 and `id` unique, so a collector sent a batch twice keeps it once.
4011
+ - **`sessionId` is one visit**, in `sessionStorage`: it survives a reload, ends with the tab, and starts again after `sessionTimeout` (30 minutes) with nothing happening. **`visitor: true`** adds a `visitorId` in `localStorage`, one browser across visits, written only once consent is granted.
4012
+ - **`consent`**: `'granted'` (default), `'pending'` (held in memory, nothing stored or sent) or `'denied'` (dropped, both ids forgotten); `analytics.consent(next)` answers it later.
4013
+ - **`content: true`** passes the stream's own option through. **`batch: { size, wait }`** (20, 5000 ms), **`filter(event)`** on the raw interaction, **`analytics.flush()`** and **`analytics.stop()`**.
4014
+ - **A transport is a function of the batch.** `beacon(url)` posts `{ "events": [...] }` with `sendBeacon` as `text/plain` (no preflight; read the body as JSON), falling back to a `keepalive` fetch. A vendor is one more: `send: (events) => events.forEach((e) => posthog.capture(e.name, e))`.
@@ -307,6 +307,19 @@ Any colour value takes a slash and a percentage:
307
307
  - A token the palette does not have, or a percentage outside 0–100, produces **no rule and no class
308
308
  name** — the same silence every unmatched value gets.
309
309
 
310
+ **Two more families, `accent` and `base`, are what every built-in component is drawn in.** They start as
311
+ copies of `indigo` and `gray`, so nothing changes until one is pointed somewhere. `Box.palette()` returns the
312
+ `vars` record that does it, for an element and its subtree — a family by name, or a ramp of your own:
313
+
314
+ ```tsx
315
+ <Box vars={Box.palette({ accent: 'orange', base: 'stone' })} /> // every Button inside is orange
316
+ <Box vars={Box.palette({ accent: { 500: 'oklch(68% .19 42)' } })} /> // a step left out keeps its default
317
+ <Box theme={{ dark: { vars: Box.palette({ accent: 'amber' }) } }} /> // nests like any vars
318
+ ```
319
+
320
+ Write `accent-*`/`base-*` in your own components when they should follow a design, and a named family when the
321
+ colour is the content (a status, a chart series). A class built from them reads `bgColor-accent-500`.
322
+
310
323
  ### Gradients, shadows and rings
311
324
 
312
325
  A gradient is a **value**, written as a record — so its stops are palette tokens, it takes the opacity
@@ -330,7 +343,10 @@ modifier, it is themed, and two elements asking for the same one share a class.
330
343
  - A stop is any colour value — a token, `blue-500/40`, a system colour, `var(--chart-1)` — or a
331
344
  `[colour, position]` pair. **Two stops minimum**, and the record is judged whole: one bad stop, one
332
345
  unknown key, two kinds at once, and the whole value emits no rule and no class name.
333
- - It writes `background-image`, so `bgGradient` and `bgImage` are the same property.
346
+ - It writes `background-image`, so `bgGradient` and `bgImage` are the same property. `bgGradient` is declared
347
+ later and wins, so a variant or a design takes a gradient off with `bgGradient: 'none'`, never `bgImage: 'none'`.
348
+ - `letterSpacing` is pixels as a number, and Tailwind's tracking names in `em` — `'tighter'` (-0.05em), `'tight'`,
349
+ `'normal'`, `'wide'`, `'wider'`, `'widest'` (0.1em) — which is what a display heading wants at every size.
334
350
 
335
351
  **Four shadows stack**, because each sets its own custom property and all four write one composed
336
352
  `box-shadow`. A ring and an elevation coexist instead of the last one winning:
@@ -1094,6 +1110,40 @@ const harbor = Box.createComponents({ button: { styles: { borderRadius: 0 } } },
1094
1110
  - **A Server Component cannot read the provider.** A client component under it can.
1095
1111
  - **Types for two designs that both name a component**: `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}` (from `@box-kite/core/types`). Extending two `ExtractComponentsAndVariants` that disagree on `button` is an interface conflict.
1096
1112
 
1113
+ ### Box.components(styles, { design }) — A Design Chosen in CSS
1114
+
1115
+ A registry is chosen by React context, so a prerendered page can only switch to it after hydration — a flash, and a
1116
+ hydration mismatch if the client renders it first. A **named design** is chosen by an attribute instead: its styles are
1117
+ merged over the app's components and applied wherever an ancestor carries `data-design="<name>"`, so a stored choice
1118
+ written onto `<html>` before the first paint is the whole fix.
1119
+
1120
+ ```tsx
1121
+ Box.components({ button: { styles: { borderRadius: 2.5 } }, dialog: { styles: { borderRadius: 5, p: 7 } } }, { design: 'ember' });
1122
+
1123
+ <Box.Theme
1124
+ use="global"
1125
+ design="classic"
1126
+ designStorageKey="design"
1127
+ globalStyles={{ dataAttr: { 'design=ember': { vars: Box.palette({ accent: 'orange', base: 'stone' }) } } }}
1128
+ >
1129
+ <App />
1130
+ </Box.Theme>;
1131
+
1132
+ const [design, setDesign] = Box.useDesign(); // setDesign(null) goes back to the design prop
1133
+ ```
1134
+
1135
+ - **Both designs are in the CSS.** A Box whose component a design changes carries the design's whole class list beside its
1136
+ own, each rule one class more specific — so every state the app's design styles (a theme, a hover, a variant) is
1137
+ outranked by the same state of the design. A component the design leaves alone costs nothing.
1138
+ - **To take something back off, write its `none`** (`shadow: 'none'`, `bgGradient: 'none'`): a design merges, it cannot delete a key.
1139
+ - **A design nested in another stops the outer one** — a `data-design="classic"` preview inside an Ember page is Classic.
1140
+ The palette is `vars`, which inherit, so that wrapper declares its own `Box.palette()` too.
1141
+ - **Props still win**, and a Box under `<Box.Components value>` resolves against that registry and no named design.
1142
+ - `Box.Theme` writes `data-design` on `<html>` (`use="global"`) or on its wrapper, restores `designStorageKey` after
1143
+ hydration without writing the default over a value a pre-paint script set, and pauses transitions for the switch.
1144
+ `applyDesignToElement` is the framework-free half, in `@box-kite/core`.
1145
+ - **A part can `extends` too**: `trigger: { extends: 'button' }` or `'dialog.title'`. It used to be silently ignored.
1146
+
1097
1147
  ---
1098
1148
 
1099
1149
  ## Extension System
@@ -2564,7 +2614,7 @@ function TrendCell({ cell }: { cell: CellModel<Row> }) {
2564
2614
  4. **fontSize divider is 16** (not 4). `fontSize={14}` → 14px
2565
2615
  5. **Spacing divider is 4**. `p={4}` → 16px (1rem)
2566
2616
  6. **Border width and lineHeight are direct px**. `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
2567
- 7. **Colors are Tailwind's OKLCH palette**: `'gray-500'`, `'blue-600'` — 26 families × 11 steps, and an opacity modifier on any of them: `bgColor="blue-500/40"` (a `color-mix`, not `opacity`)
2617
+ 7. **Colors are Tailwind's OKLCH palette**: `'gray-500'`, `'blue-600'` — 26 families × 11 steps, and an opacity modifier on any of them: `bgColor="blue-500/40"` (a `color-mix`, not `opacity`). The built-in components are drawn in two more, `accent` and `base`, which `Box.palette()` repoints
2568
2618
  8. **Breakpoints are mobile-first**: base → sm → md → lg → xl → xxl
2569
2619
  9. **Theme styles nest**: `theme={{ dark: { hover: { ... } } }}`
2570
2620
  10. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`. `data-*` and `aria-*` go there too — a `data-state` written at the top level typechecks and is then dropped
@@ -3886,3 +3936,79 @@ call being shown and `renderAndWaitForResponse` is a question whose `respond` is
3886
3936
  `onDecisionChange` calls. json-render is the same `z.fromJSONSchema` over `catalog()` itself.
3887
3937
 
3888
3938
  Full reference: `docs/interop.md`.
3939
+
3940
+ ## Interaction events (`Box.onInteraction`)
3941
+
3942
+ One listener hears every interaction, in one typed stream, so an analytics call, an audit log or a session recorder is one function:
3943
+
3944
+ ```tsx
3945
+ const stop = Box.onInteraction((event) => {
3946
+ analytics.track(`${event.component}.${event.type}`, {
3947
+ kind: event.kind,
3948
+ source: event.source,
3949
+ reason: event.reason,
3950
+ value: event.value,
3951
+ at: event.time,
3952
+ });
3953
+ });
3954
+ ```
3955
+
3956
+ - **`kind`** is who reported it. `'change'` is a component's own change: `type` is the callback it mirrors without its `on`, `reason` is the word that callback is given (`'escape'`, `'outside-pointer'`, `'keyboard'`…), `value` the new value and `details` the rest of what the callback is told. `'dom'` is a `click` or a `change` on anything a component styled, named by the nearest `data-bk` (below). `'custom'` is the app's own, from `Box.reportInteraction`.
3957
+ - **The stream is typed**: narrow on `kind`, then `component` and `type`, and `value` is what that change carries — `if (e.kind === 'change' && e.component === 'datagrid' && e.type === 'sortingChange') e.value?.columnKey`. The whole map is `ComponentInteractions`, exported from `@box-kite/react` with `InteractionEvent`.
3958
+ - **`value` and `details` are plain JSON copies**, so an event goes to a log or a beacon as it stands: a `Date` is its ISO string, a `Set` an array, and a function, a React element, a DOM node or a class instance is left out.
3959
+ - **What a person typed is content, and a listener asks for it.** A DataGrid search, filter values, a cell's value before and after an edit (and a refusal's message), the cells a paste wrote, a date picked or typed, the rows a `Combobox` selected and a toast's text are left out of what a listener is handed, and `url` has no query string and no hash. `Box.onInteraction(listener, { content: true })` hands all of it over. A report names its content in `content: ['value', 'oldValue']` (the value and any `details` keys), which is how `Box.reportInteraction` marks an app's own.
3960
+ - **`source`** is `'app'` when code did it — the reasons `imperative`, `programmatic` and `timeout`, a `toast()` call, an untrusted DOM event — and `'user'` otherwise.
3961
+ - **`event` is the DOM event behind it** — passed by the component, or the one the browser was dispatching when the change happened, so a DataGrid sorted by a header press carries that press. **`target`** is the control that handled it (the tab, not the icon inside it), else the component's own element. `timeStamp` is `performance.now()`, `time` is `Date.now()` and `url` is the page it happened on, so an event delivered after a navigation still names its own.
3962
+ - **It fires whether or not the app passed the callback, and it is fire and forget.** An event is queued as it happens and delivered in order, batched, in a **background task** after the handler that caused it (`scheduler.postTask` at `background` priority, `setTimeout` where there is none), so a slow listener cannot delay the click, the render or the paint it reports, and a listener that throws goes to `reportError` while the rest still run. `pagehide` delivers whatever is still queued, and **`Box.flushInteractions()`** delivers it now, which is what an analytics sender calls before it sends its last batch, and what a test calls before it asserts.
3963
+ - **With nobody listening, a report costs one comparison** and nothing is kept, so subscribe in the module that boots the app: what happens before the first listener is gone. The delivery itself (the batching, the JSON copies, the press listeners) is a chunk of its own, fetched with the first subscription; anything reported while it is on its way waits for it.
3964
+ - **One choice is one event, from the component the app rendered.** A component built from others keeps its parts quiet: a day picked in a `DatePicker` is `datePicker.valueChange` and `datePicker.openChange` (`select`), never `calendar.valueChange` as well. The same holds for the `Combobox` in a `TimeZoneSelect` and for the DataGrid's own filters, editors and panels, whose changes the grid reports itself — while a column's `Cell` and a `rowDetail`, which are the app's, report as usual wherever the grid renders them.
3965
+ - **On a server nothing listens** — no person is interacting there, and a subscription there is a no-op — and the channel lives on `globalThis` under `Symbol.for('box-kite.interactions@2')`, so two copies of the package share it.
3966
+
3967
+ | `component` | `type` (and `reason`) |
3968
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
3969
+ | `dialog`, `alertDialog`, `popover`, `menu`, `datePicker`, `combobox`, `dropdown`, `timeZoneSelect`, `collapsible`, `toolCall`, `reasoning`, `swipeItem` | `openChange` · `menu.sub` `openChange` only when the keyboard opens it, with `details.item` |
3970
+ | `tabs`, `accordion`, `radioGroup`, `dropdown`, `combobox`, `calendar`, `dateField`, `datePicker`, `dateRangePicker`, `timeZoneSelect`, `menu.radioGroup` | `valueChange` |
3971
+ | `menu.item` | `select` — the value is the item's `value` prop, else its text · `menu.checkboxItem` `checkedChange`, with `details.item` · `approval` `decisionChange` |
3972
+ | `calendar` | `pageChange` (`next`/`previous`), `viewChange` (`zoom-in`/`zoom-out`) |
3973
+ | `slider` | `valueCommit` · `dashboard` `layoutCommit`, `grab`, `grabCancel` · `widget` `refresh` (`refresh`/`retry`) · `sortableList` `orderChange`, `orderCommit`, `grab`, `grabCancel` · `swipeItem` `fullSwipe` |
3974
+ | `form` | `submit` — the value is the **field names**, never what was typed into them |
3975
+ | `toaster` | `show` (reason: the kind), `update`, `dismiss` (`timeout`/`close`/`action`/`imperative`), `action` (an action that leaves the toast up) |
3976
+ | `datagrid` | `sortingChange`, `globalFilterChange`, `columnFiltersChange`, `paginationChange`, `selectedRowKeysChange`, `expandedRowKeysChange`, `expandedTreeKeysChange`, `rangeChange` (a marked block only), `copy`, `cellEditStart`, `cellEdit`, `cellEditCancel`, `cellEditReject`, `paste`, `columnPinChange`, `columnVisibilityChange`, `columnResize`, `groupingChange`, `groupExpandedChange`, `selectionViewChange`, `export`, `retry` — and on a phone `recordChange`, `sheetChange`, `selectModeChange`, `showMore` |
3977
+
3978
+ **Nothing reports per frame, per hover or per keystroke.** A drag reports where it ends (`valueCommit`, `layoutCommit`, `columnResize`, a range block), so a slider's `valueChange` and a dashboard's `layoutChange` are not events here; neither is a tooltip shown on hover, a submenu opened by the pointer, the text typed into a combobox or a dropdown's search, focus moving in a calendar or a grid, nor a page reset that a sort or a filter caused. A change the app makes through a ref (`exportCsv()`, `clearEdits()`, `refresh()`) does not report; one that goes through a component's state (closing a `Dialog` from code) reports with `source: 'app'`.
3979
+
3980
+ **A press is a `dom` event, and it needs `data-bk`.** `Box.configure({ componentAttribute: true })` writes `data-bk="<component key>"` on every element a component styles, user components from `Box.components()` included, and the channel names a `click` or a `change` by the nearest one — on the control it hit, with `details.interactive` saying whether a control was hit at all, so a dead click is countable. A press that changed something arrives as that change, never twice: `tabs.valueChange` carries the click as its `event` and the tab as its `target`, and so does an app's own `Box.reportInteraction` made inside the handler. Only a press nothing explained — a plain `Button`, a dead click — is a `dom` event. Without the attribute nothing can be named, and development says so once in the console.
3981
+
3982
+ ```tsx
3983
+ Box.configure({ componentAttribute: true }); // in a module both the server and the browser run
3984
+ ```
3985
+
3986
+ An app's own `data-bk` in `props` wins. Set the flag before the first render, on both sides, or a hydrating browser sees attributes the server never wrote. The half of the channel that delivers and watches the page is a chunk of under 1.5 KB, fetched with the first subscription; in a test, `await vi.dynamicImportSettled()` after subscribing, before the first assertion.
3987
+
3988
+ **A moment of the app's own is `Box.reportInteraction`.** Box never wraps the handlers an app passes in `props`: wrapping them all would cost every render, report a hover or a scroll per frame, and name nothing better than `button`. So a hover, a section scrolled into view or a wizard step is reported where it happens, in the same shape, to the same listeners, fire and forget:
3989
+
3990
+ ```tsx
3991
+ <Button props={{ onMouseEnter: (event) => Box.reportInteraction({ component: 'pricing-cta', type: 'mouseEnter', event }) }}>Pricing</Button>
3992
+ ```
3993
+
3994
+ It is a plain function, not a hook, so it works in an effect, a fetch callback or a module with no React in it. `event` may be React's synthetic one; `target`, `source`, `timeStamp`, `time` and `url` are filled in. Keep it to discrete moments, the rule the components follow.
3995
+
3996
+ ### Sending it somewhere: `@box-kite/react/analytics`
3997
+
3998
+ `createAnalytics({ send })` is that stream collected: every interaction as a flat JSON record with a session id, batched, held until consent is granted, and sent as the page is hidden. No React and no engine, 1.9 KB gzipped.
3999
+
4000
+ ```ts
4001
+ import { beacon, consoleLog, createAnalytics } from '@box-kite/react/analytics';
4002
+
4003
+ const analytics = createAnalytics({
4004
+ send: [beacon('https://collect.example.com/events'), consoleLog()],
4005
+ consent: 'pending', // nothing stored or sent until the reader answers
4006
+ });
4007
+ analytics.consent('granted');
4008
+ ```
4009
+
4010
+ - **A record** is `{ id, name, kind, component, type, reason, value, details, source, trigger, label, time, url, sessionId, visitorId? }`: `name` is `component.type`, `trigger` the DOM event's type, `label` the words on a pressed button, link, tab or menu item (never a cell's or a field's without `content`), `time` ISO 8601 and `id` unique, so a collector sent a batch twice keeps it once.
4011
+ - **`sessionId` is one visit**, in `sessionStorage`: it survives a reload, ends with the tab, and starts again after `sessionTimeout` (30 minutes) with nothing happening. **`visitor: true`** adds a `visitorId` in `localStorage`, one browser across visits, written only once consent is granted.
4012
+ - **`consent`**: `'granted'` (default), `'pending'` (held in memory, nothing stored or sent) or `'denied'` (dropped, both ids forgotten); `analytics.consent(next)` answers it later.
4013
+ - **`content: true`** passes the stream's own option through. **`batch: { size, wait }`** (20, 5000 ms), **`filter(event)`** on the raw interaction, **`analytics.flush()`** and **`analytics.stop()`**.
4014
+ - **A transport is a function of the batch.** `beacon(url)` posts `{ "events": [...] }` with `sendBeacon` as `text/plain` (no preflight; read the body as JSON), falling back to a `keepalive` fetch. A vendor is one more: `send: (events) => events.forEach((e) => posthog.capture(e.name, e))`.
package/analytics.cjs ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./platform.cjs"),t=require("./interactions.cjs");var n=18e5;function r(){if(typeof crypto.randomUUID==`function`)return crypto.randomUUID();let e=Array.from(crypto.getRandomValues(new Uint8Array(16)),e=>e.toString(16).padStart(2,`0`)).join(``);return`${e.slice(0,8)}-${e.slice(8,12)}-4${e.slice(13,16)}-${`89ab`[parseInt(e[16],16)%4]}${e.slice(17,20)}-${e.slice(20,32)}`}function i(e,t){try{return e().getItem(t)}catch{return null}}function a(e,t,n){try{n===null?e().removeItem(t):e().setItem(t,n)}catch{}}var o=()=>sessionStorage,s=()=>localStorage;function c(e,t){let n=`${e}.session`,c=`${e}.visitor`,l,u;return{sessionAt(e){if(!l)try{l=JSON.parse(i(o,n)??`null`)??void 0}catch{l=void 0}return!l||e-l.last>t?l={id:r(),last:e}:l.last=Math.max(l.last,e),a(o,n,JSON.stringify(l)),l.id},visitor(){return u??=i(s,c)??r(),a(s,c,u),u},forget(){l=void 0,u=void 0,a(o,n,null),a(s,c,null)}}}var l=200,u=64,d=`button,a[href],[role=tab],[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio],summary`;function f(e,t){if(!e||!t&&!e.matches(d))return;let n=e.getAttribute(`aria-label`)??e.textContent?.trim().replace(/\s+/g,` `);return n?n.length>u?`${n.slice(0,63)}…`:n:void 0}function p(i){let{send:a,visitor:o=!1,content:s=!1,sessionTimeout:u=n,filter:d,storageKey:p=`box-kite`}=i,m=i.batch?.size??20,h=i.batch?.wait??5e3,g=Array.isArray(a)?a:[a];if(!e.r())return{consent:()=>{},flush:()=>{},stop:()=>{}};let _=c(p,u),v=i.consent??`granted`,y=[],b,x=()=>{if(clearTimeout(b),b=void 0,v!==`granted`||y.length===0)return;let e=o?_.visitor():void 0,t=y.map(({at:t,...n})=>({...n,time:new Date(t).toISOString(),sessionId:_.sessionAt(t),...e&&{visitorId:e}}));y=[];for(let e=0;e<t.length;e+=m){let n=t.slice(e,e+m);g.forEach(e=>e(n))}},S=t.i(e=>{if(v===`denied`||d&&!d(e))return;let t={id:r(),name:`${e.component}.${e.type}`,kind:e.kind,component:e.component,type:e.type,reason:e.reason,value:e.value,details:e.details,source:e.source,trigger:e.event?.type,label:f(e.target,s),url:e.url,at:e.time};y.push(t),v===`pending`?y.length>l&&y.shift():y.length>=m?x():b??=setTimeout(x,h)},{content:s}),C=()=>{document.visibilityState===`hidden`&&(t.n(),x())},w=()=>{t.n(),x()};return document.addEventListener(`visibilitychange`,C),addEventListener(`pagehide`,w),{consent(e){v=e,e===`denied`?(y=[],_.forget()):x()},flush:w,stop(){w(),S(),document.removeEventListener(`visibilitychange`,C),removeEventListener(`pagehide`,w)}}}function m(e){return t=>{let n=JSON.stringify({events:t});navigator.sendBeacon?.(e,new Blob([n],{type:`text/plain;charset=UTF-8`}))||fetch(e,{method:`POST`,body:n,keepalive:!0,headers:{"content-type":`text/plain;charset=UTF-8`}}).catch(()=>{})}}function h(e=`[interaction]`){return t=>t.forEach(t=>console.info(e,t))}exports.beacon=m,exports.consoleLog=h,exports.createAnalytics=p;
package/analytics.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `Box.onInteraction`, collected (`@box-kite/react/analytics`): every interaction as a flat JSON record carrying a session id,
3
+ * batched, held until consent is granted and sent as the page is hidden. Framework-free: no React and no engine, only the channel.
4
+ *
5
+ * ```ts
6
+ * const analytics = createAnalytics({ send: beacon('/collect') });
7
+ * ```
8
+ */
9
+ export { beacon, consoleLog, createAnalytics } from './utils/analytics/analytics';
10
+ export type { Analytics, AnalyticsConsent, AnalyticsEvent, AnalyticsOptions, AnalyticsTransport } from './utils/analytics/analytics';