@pebble-software/bonsai 0.0.8 → 0.1.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +371 -88
  3. package/dist/ast.js +10 -0
  4. package/dist/ast.js.map +1 -0
  5. package/dist/chunks/builder.meta-WpXc5VBB.js +666 -0
  6. package/dist/chunks/builder.meta-WpXc5VBB.js.map +1 -0
  7. package/dist/chunks/button-jfJanilw.js +161 -0
  8. package/dist/chunks/button-jfJanilw.js.map +1 -0
  9. package/dist/chunks/create-renderer-DHneTPd-.js +280 -0
  10. package/dist/chunks/create-renderer-DHneTPd-.js.map +1 -0
  11. package/dist/chunks/index-DzpRdatr.js +359 -0
  12. package/dist/chunks/index-DzpRdatr.js.map +1 -0
  13. package/dist/chunks/render-shared-Ci28BpkI.js +1989 -0
  14. package/dist/chunks/render-shared-Ci28BpkI.js.map +1 -0
  15. package/dist/components/core/bonsai.d.ts +5 -0
  16. package/dist/components/core/render-shared.d.ts +123 -0
  17. package/dist/components/core/render-tree-client.d.ts +4 -0
  18. package/dist/components/core/render-tree-server.d.ts +2 -0
  19. package/dist/components/ui/a.d.ts +3 -0
  20. package/dist/components/ui/accordion.d.ts +17 -0
  21. package/dist/components/ui/alert-dialog.d.ts +23 -0
  22. package/dist/components/ui/alert.d.ts +12 -0
  23. package/dist/components/ui/avatar.d.ts +21 -0
  24. package/dist/components/ui/badge.d.ts +10 -0
  25. package/dist/components/ui/breadcrumb.d.ts +10 -0
  26. package/dist/components/ui/button.d.ts +14 -0
  27. package/dist/components/ui/calendar.d.ts +9 -0
  28. package/dist/components/ui/card.d.ts +15 -0
  29. package/dist/components/ui/checkbox.d.ts +7 -0
  30. package/dist/components/ui/collapsible.d.ts +5 -0
  31. package/dist/components/ui/combobox.d.ts +31 -0
  32. package/dist/components/ui/command.d.ts +23 -0
  33. package/dist/components/ui/dialog.d.ts +23 -0
  34. package/dist/components/ui/drawer.d.ts +22 -0
  35. package/dist/components/ui/dropdown-menu.d.ts +29 -0
  36. package/dist/components/ui/form.d.ts +24 -0
  37. package/dist/components/ui/h.d.ts +10 -0
  38. package/dist/components/ui/input-group.d.ts +18 -0
  39. package/dist/components/ui/input-otp.d.ts +11 -0
  40. package/dist/components/ui/input.d.ts +3 -0
  41. package/dist/components/ui/label.d.ts +10 -0
  42. package/dist/components/ui/loader.d.ts +14 -0
  43. package/dist/components/ui/p.d.ts +11 -0
  44. package/dist/components/ui/popover.d.ts +9 -0
  45. package/dist/components/ui/progress.d.ts +31 -0
  46. package/dist/components/ui/scroll-area.d.ts +13 -0
  47. package/dist/components/ui/select.d.ts +17 -0
  48. package/dist/components/ui/separator.d.ts +8 -0
  49. package/dist/components/ui/sidebar.d.ts +70 -0
  50. package/dist/components/ui/signature-pad.d.ts +7 -0
  51. package/dist/components/ui/skeleton.d.ts +7 -0
  52. package/dist/components/ui/switch.d.ts +8 -0
  53. package/dist/components/ui/table.d.ts +14 -0
  54. package/dist/components/ui/tabs.d.ts +15 -0
  55. package/dist/components/ui/textarea.d.ts +5 -0
  56. package/dist/components/ui/toast.d.ts +46 -0
  57. package/dist/components/ui/tooltip.d.ts +15 -0
  58. package/dist/constants.d.ts +1 -0
  59. package/dist/constants.js +11 -0
  60. package/dist/constants.js.map +1 -0
  61. package/dist/core.d.ts +12 -0
  62. package/dist/core.js +17 -0
  63. package/dist/core.js.map +1 -0
  64. package/dist/hooks/haptics.d.ts +8 -0
  65. package/dist/hooks/use-mobile.d.ts +1 -0
  66. package/dist/index.css +7 -0
  67. package/dist/index.d.ts +27 -339
  68. package/dist/index.js +532 -4297
  69. package/dist/index.js.map +1 -0
  70. package/dist/lib/ast/cache.d.ts +8 -0
  71. package/dist/lib/ast/evaluator.d.ts +2 -0
  72. package/dist/lib/ast/helpers.d.ts +2 -0
  73. package/dist/lib/ast/index.d.ts +29 -0
  74. package/dist/lib/ast/setup.d.ts +2 -0
  75. package/dist/lib/ast/types.d.ts +1 -0
  76. package/dist/lib/ast/utils.d.ts +4 -0
  77. package/dist/lib/haptics.d.ts +8 -0
  78. package/dist/lib/registry/drift.d.ts +17 -0
  79. package/dist/lib/registry/identity.d.ts +24 -0
  80. package/dist/lib/registry/json-schema-to-zod.d.ts +32 -0
  81. package/dist/lib/registry/manifest-schema.d.ts +62 -0
  82. package/dist/lib/registry/manifest.d.ts +26 -0
  83. package/dist/lib/registry/merge-generated.d.ts +7 -0
  84. package/dist/lib/registry/pebble-registry.d.ts +1 -0
  85. package/dist/lib/registry/types.d.ts +29 -0
  86. package/dist/lib/registry/validate.d.ts +18 -0
  87. package/dist/lib/renderer/compile-template.d.ts +70 -0
  88. package/dist/lib/ui/registry.d.ts +19 -0
  89. package/dist/lib/utils.d.ts +3 -0
  90. package/dist/library/constants/src/components.d.ts +59 -0
  91. package/dist/library/constants/src/index.d.ts +2 -0
  92. package/dist/library/constants/src/webhook-events.d.ts +6 -0
  93. package/dist/library/schemas/src/bonsai.schema.d.ts +24 -0
  94. package/dist/library/schemas/src/builder.meta.d.ts +26 -0
  95. package/dist/presets/calendar.js +374 -0
  96. package/dist/presets/calendar.js.map +1 -0
  97. package/dist/presets/signature.js +405 -0
  98. package/dist/presets/signature.js.map +1 -0
  99. package/dist/renderer/core-registry.d.ts +52 -0
  100. package/dist/renderer/create-renderer.d.ts +38 -0
  101. package/dist/renderer/presets/calendar.d.ts +5 -0
  102. package/dist/renderer/presets/signature.d.ts +6 -0
  103. package/dist/schemas.d.ts +2 -0
  104. package/dist/schemas.js +13 -0
  105. package/dist/schemas.js.map +1 -0
  106. package/dist/styles/css/base.css +144 -0
  107. package/dist/styles/css/fonts.css +25 -0
  108. package/dist/styles/css/maplibre.css +29 -0
  109. package/dist/styles/css/overrides.css +7 -0
  110. package/dist/styles/css/tailwind.css +1 -0
  111. package/dist/styles/css/theme.css +197 -0
  112. package/dist/styles/css/tw-animate.css +1 -0
  113. package/dist/styles/css/utilities.css +44 -0
  114. package/dist/styles/presets.json +20 -0
  115. package/dist/styles/vendor/maplibre-gl.css +701 -0
  116. package/dist/styles/vendor/tw-animate.css +692 -0
  117. package/dist/types.d.ts +1 -0
  118. package/package.json +64 -19
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  > ⚠️ **Active development / preview** — this package is pre-1.0 and evolving quickly. APIs, the configuration schema, and the component set may change between releases without a deprecation cycle. Pin an exact version and read release notes before upgrading. Feedback and issues are very welcome!
8
8
 
9
- The React runtime that turns a validated Bonsai configuration into a fully interactive, styled experience. It's built for UI that has to be delivered remotely (email, SMS, embedded experiences) but rendered locally with real React components, consistent styling, and predictable data flow.
9
+ The React runtime that turns a Bonsai configuration (a JSON template) into a fully interactive, styled experience. It's built for UI that has to be delivered remotely (email, SMS, embedded experiences) but rendered locally with real React components, consistent styling, and predictable data flow.
10
10
 
11
11
  Bonsai is the rendering engine behind [Pebble Bonsai](https://github.com/PebbleSoftwareInc/pebble-bonsai), an open-source form and signature service — but the renderer is useful anywhere you need to ship UI as data: define a screen as a JSON template, bind it to your data with `{{ }}` expressions, and let one component render it on the server (SSR, PDF) or in the browser (fully interactive).
12
12
 
@@ -16,6 +16,7 @@ Bonsai is the rendering engine behind [Pebble Bonsai](https://github.com/PebbleS
16
16
  - [Installation](#installation)
17
17
  - [Quick start](#quick-start)
18
18
  - [Package exports](#package-exports)
19
+ - [The `Bonsai` component](#the-bonsai-component)
19
20
  - [Configuration schema](#configuration-schema)
20
21
  - [Dynamic data binding](#dynamic-data-binding)
21
22
  - [Expression language](#expression-language)
@@ -24,50 +25,62 @@ Bonsai is the rendering engine behind [Pebble Bonsai](https://github.com/PebbleS
24
25
  - [Component reference](#component-reference)
25
26
  - [Styling](#styling)
26
27
  - [Extending Bonsai](#extending-bonsai)
28
+ - [Performance](#performance)
27
29
  - [Development](#development)
28
30
 
29
31
  ## Features
30
32
 
31
- - **Schema-driven UI** — every screen is a Zod-validated `Config` tree, so the renderer only ever processes known-safe structures.
33
+ - **Template-driven UI** — every screen is a JSON `Config` tree of known component types, rendered by one component.
32
34
  - **Universal rendering** — one component (`Bonsai`) picks server (SSR) or client (interactive) rendering automatically based on environment, with `web`, `pdf`, and `preview` render modes.
33
- - **Composable components** — schema component identifiers map to real React implementations via a `componentMap`, so the same schema can target different component sets.
35
+ - **Pluggable component registry** — schema component identifiers map to real React implementations, and `createBonsaiRenderer` lets you build a renderer with your own components added or swapped in.
36
+ - **Opt-in heavy components** — the calendar and signature controls live in separate presets, so the lightweight `/core` entry never pulls in `react-day-picker` or `react-signature-canvas`.
34
37
  - **Isolated per-instance state** — each mounted `Bonsai` tree owns its own `valtio` store, scoped via React context. Nothing about which data renders lives in shared or module-level state.
35
38
  - **Fine-grained reactivity** — every leaf node reads the store itself and evaluates only its own bindings, so editing one field re-renders just that field and whatever depends on it.
36
- - **Structural nodes** — `each` and `if` handle looping and conditional rendering directly in the schema, with two-way binding preserved inside loops.
39
+ - **Structural nodes** — `each` and `if` handle looping and conditional rendering directly in the template, with two-way binding preserved inside loops.
37
40
  - **Sandboxed expression language** — `{{ }}` bindings are evaluated by a small, fixed-surface formula language, never executable JavaScript.
38
- - **Tailwind CSS v4 design system** — a themeable token set (colors, typography, spacing, shadows) drives every component, and templates can layer on any Tailwind utility via `className`.
41
+ - **Tailwind CSS v4 design system** — a themeable token set (colors, typography, spacing, shadows) drives every component, and templates can layer on Tailwind utilities via `className`.
39
42
 
40
43
  ## Installation
41
44
 
45
+ The package ships three kinds of entry point (see [Package exports](#package-exports)). Install the peer dependencies for the ones you import.
46
+
47
+ **Lightweight core** (`@pebble-software/bonsai/core`) — needs only React:
48
+
42
49
  ```bash
43
- npm install @pebble-software/bonsai
44
- # or
45
- pnpm add @pebble-software/bonsai
46
- # or
47
- yarn add @pebble-software/bonsai
50
+ npm install @pebble-software/bonsai react react-dom
48
51
  ```
49
52
 
50
- Peer dependencies:
53
+ **Batteries-included** (`@pebble-software/bonsai`) — the root entry imports every preset, so all of the optional peers must be installed:
51
54
 
52
- - `react` and `react-dom` 18 or 19
53
- - A bundler that understands ES modules (Vite, Next, Rspack, etc.)
55
+ ```bash
56
+ npm install @pebble-software/bonsai react react-dom react-day-picker react-signature-canvas@1.1.0-alpha.2
57
+ ```
54
58
 
55
- > **Preview note:** while the package is pre-1.0, pin an exact version (`npm install @pebble-software/bonsai --save-exact`) — minor releases may contain breaking changes.
59
+ Optional peer dependencies, by preset:
56
60
 
57
- ### Working in this monorepo
61
+ | Peer dependency | Needed by |
62
+ | --- | --- |
63
+ | `react-day-picker` | `@pebble-software/bonsai/presets/calendar` and `@pebble-software/bonsai` |
64
+ | `react-signature-canvas` (`1.1.0-alpha.2`) | `@pebble-software/bonsai/presets/signature` and `@pebble-software/bonsai` |
58
65
 
59
- ```bash
60
- pnpm install
61
- pnpm --filter @pebble-software/bonsai run build
62
- ```
66
+ npm and pnpm don't auto-install optional peers — if you import the root entry without them, your bundler will fail to resolve them.
67
+
68
+ Requirements:
63
69
 
64
- The shared workspaces (`@bonsai/constants`, `@bonsai/schemas`) are aliased to their sources and bundled directly into `dist/` by Vite, so downstream consumers only ever install this one package.
70
+ - `react` and `react-dom` 18 or 19
71
+ - A bundler that understands ES modules (Vite, Next.js, Rspack, etc.) — the package is ESM-only.
72
+ - Tailwind CSS v4 in your build, for the styles — `index.css` ships as Tailwind source (see [Styling](#styling)).
73
+
74
+ > **Preview note:** while the package is pre-1.0, pin an exact version (`npm install @pebble-software/bonsai --save-exact`) — minor releases may contain breaking changes.
65
75
 
66
76
  ## Quick start
67
77
 
68
78
  ```tsx
69
- import { Bonsai, type Config } from "@pebble-software/bonsai";
79
+ import { Bonsai, type Config } from "@pebble-software/bonsai/core";
80
+ // Styles: `@import "@pebble-software/bonsai/index.css";` in your Tailwind v4 CSS entry — see Styling.
70
81
 
82
+ // Module scope: templates are compiled once per object identity, and `data`
83
+ // is read by identity too (see "The `data` prop" below).
71
84
  const template: Config = {
72
85
  type: "card",
73
86
  args: { className: "p-6 flex flex-col gap-4" },
@@ -79,70 +92,191 @@ const template: Config = {
79
92
  },
80
93
  {
81
94
  type: "input",
82
- args: { id: "name", type: "text", value: "{{profile.name}}" },
95
+ args: { id: "name", value: "{{profile.name}}" },
83
96
  },
84
97
  {
85
98
  type: "button",
86
- args: { variant: "animated" },
99
+ args: { type: "submit" },
87
100
  children: ["Continue"],
88
101
  },
89
102
  ],
90
103
  };
91
104
 
105
+ const initialData = { profile: { name: "Ada Lovelace" } };
106
+
92
107
  export function Example() {
93
108
  return (
94
109
  <Bonsai
95
110
  template={template}
96
- captureUrl="/v1/capture/some-form-id"
97
- expiry="2026-12-31T00:00:00Z"
98
- data={{ profile: { name: "Ada Lovelace" } }}
111
+ data={initialData}
112
+ onSubmit={async (data) => {
113
+ const res = await fetch("/api/profile", {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify(data),
117
+ });
118
+ if (!res.ok) throw new Error(`Save failed: ${res.status}`);
119
+ }}
99
120
  />
100
121
  );
101
122
  }
102
123
  ```
103
124
 
104
- `Bonsai` renders server-side (a plain, single-pass render) when `window` is undefined, and client-side (an interactive, `valtio`-backed tree) otherwise — no extra setup required either way.
125
+ - The stylesheet is **not** imported by the JavaScript entries — `@import` `@pebble-software/bonsai/index.css` from your Tailwind v4 CSS entry (see [Styling](#styling)).
126
+ - Buttons default to `type="button"`; give the one that should submit the form `type: "submit"`.
127
+ - `Bonsai` renders server-side (a plain, single-pass render) when `window` is undefined, and client-side (an interactive, `valtio`-backed tree) otherwise — no extra setup required either way.
128
+ - **React Server Components (Next.js App Router):** the published files carry no `"use client"` directive. Render `Bonsai` from a module marked `"use client"`; it is still server-rendered as part of that client component.
105
129
 
106
130
  ## Package exports
107
131
 
108
- | Export | Type | Description |
109
- | --- | --- | --- |
110
- | `Bonsai` | component | Universal entry point. Renders the SSR path on the server and the interactive client path in the browser. |
111
- | `useUpdateData()` | hook | Returns an `updateData(path, value)` function bound to the nearest `Bonsai` instance's store. Must be called from a component mounted inside it. |
112
- | `componentMap` | object | Maps schema component identifiers to React implementations. |
113
- | `controlledInputMap` | object | Defines how specific component types behave as controlled inputs (value prop, change handler, value extractor). |
114
- | `Config` | type | Runtime type describing a Bonsai node. |
115
- | `SignatureProps` | type | Props contract for the built-in signature capture component. |
116
-
117
- `Bonsai` accepts:
132
+ | Entry point | What it gives you |
133
+ | --- | --- |
134
+ | `@pebble-software/bonsai` | Batteries-included: `Bonsai` bound to every built-in component (`pebbleRegistry`), including calendar and signature, plus the registry tooling (`validateTemplate`, manifests, drift checks). Requires all optional peers. |
135
+ | `@pebble-software/bonsai/core` | Lightweight: `Bonsai` bound to the core components only (no calendar or signature). Requires only React. |
136
+ | `@pebble-software/bonsai/presets/calendar` | `calendarPreset` and the `Calendar` component. |
137
+ | `@pebble-software/bonsai/presets/signature` | `signaturePreset`, the `Signature` component, and the `SignatureProps` type (the signature component's props). |
138
+ | `@pebble-software/bonsai/index.css` | The Tailwind v4 theme source, for your own Tailwind build (see [Styling](#styling)). |
139
+ | `@pebble-software/bonsai/schemas` | Template tooling for editors: the zod `Config` and `NodeSchemas`, plus builder metadata (`getNodeRules`, `getArgsSchema`, `fieldHints`, `FieldControl`, `FieldGroup`). |
140
+ | `@pebble-software/bonsai/constants` | Component type names (`COMPONENT`, `COMPONENT_LIST`) and webhook event names. |
141
+ | `@pebble-software/bonsai/ast` | The expression engine (`compileExpression`, `isExpression`, `parseExpression`, `extractVariables`, `clearExpressionCache`). |
142
+ | `@pebble-software/bonsai/styles/*` | The raw layer stylesheets (`css/*.css`, `vendor/*.css`) and `presets.json`, for compiling template CSS yourself, e.g. `styles/css/theme.css?raw` in Vite. |
143
+
144
+ Named exports of `@pebble-software/bonsai` and `@pebble-software/bonsai/core`:
145
+
146
+ | Export | Entry | Kind | Description |
147
+ | --- | --- | --- | --- |
148
+ | `Bonsai` | both | component | Universal renderer. Renders the SSR path on the server and the interactive client path in the browser. Bound to the full registry (root) or the core registry (`/core`). |
149
+ | `createBonsaiRenderer(options?)` | both | function | Returns `{ Bonsai }` bound to `options.registry` (default: the core registry) plus the `components`/`controlledInputs` you pass (yours win on a key collision). See [Extending Bonsai](#extending-bonsai). |
150
+ | `useUpdateData()` | both | hook | Returns an `updateData(path, value)` function bound to the nearest `Bonsai` instance's store. Client render only — see [Controlled inputs & state](#controlled-inputs--state). |
151
+ | `pebbleRegistry` | root | object | The full built-in `BonsaiRegistry` (core + every preset, with each component's `propsSchema`). |
152
+ | `componentMap`, `controlledInputMap` | root | object | The same built-ins as plain component / controlled-input maps. Read-only reference: renderers copy their registry when created, so mutating these has no effect — use `createBonsaiRenderer` instead. |
153
+ | `coreRegistry` | both | object | The core built-in `BonsaiRegistry` (no calendar or signature). |
154
+ | `coreComponents`, `coreControlledInputs` | `/core` | object | The core built-ins as plain component / controlled-input maps. |
155
+ | `extendRegistry(base, preset)`, `defineBonsaiRegistry`, `defineComponent` | both | function | Build a `BonsaiRegistry`: layer a preset over a base registry, or declare one from scratch. Pass it as `createBonsaiRenderer({ registry })` or per instance as `<Bonsai registry={…} />`. |
156
+ | `useBonsaiRenderMode()` | both | hook | The nearest `Bonsai` root's `render` mode (`web` / `pdf` / `preview`), for components that render differently in a PDF. |
157
+ | `Config` | both | type | A template node. Currently typed as `any` — see [Configuration schema](#configuration-schema). |
158
+ | `BonsaiProps` | both | type | Props of `Bonsai`. |
159
+ | `BonsaiRendererOptions`, `BonsaiRendererInstance` | both | type | Argument and return type of `createBonsaiRenderer`. |
160
+ | `BonsaiRegistry`, `ComponentDefinition`, `ControlledFieldSpec`, `ComponentCapability` | both | type | The registry the renderer resolves component types against. |
161
+ | `BonsaiPreset`, `ComponentRegistry`, `ControlledInput`, `ControlledInputEntry`, `ControlledInputRegistry` | both | type | Preset shapes, for building your own presets. |
162
+ | `validateTemplate`, `resolveRegistryPin`, `resolveManifest`, `loadRegistryManifest`, `checkArtifactDrift`, … | root | function | Registry tooling: validate a template against a registry, resolve registry pins and manifests, check generated artifacts for drift. See [Validating templates](#validating-templates). |
163
+
164
+ ## The `Bonsai` component
118
165
 
119
166
  ```ts
120
167
  interface BonsaiProps {
121
168
  template: Config;
122
- captureUrl: string; // POST target for the submit handler
123
- expiry: string;
124
- data?: any; // initial store data
169
+ data?: Record<string, unknown>;
170
+ onDataChange?: (data: Record<string, unknown>) => void;
171
+ onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
172
+ onError?: (error: unknown) => void;
173
+ captureUrl?: string;
174
+ expiry?: string;
125
175
  status?: "pending" | "captured" | "completed" | "error" | "revoked";
126
176
  render?: "web" | "pdf" | "preview";
177
+ as?: "form" | "div";
178
+ registry?: BonsaiRegistry; // overrides the renderer's registry for this instance
127
179
  }
128
180
  ```
129
181
 
130
- - `render: "web"` (default) mounts the tree inside a `<form>` whose submit handler POSTs the captured data to `captureUrl`. `"preview"` renders the same form UI flagged with `data-mode="preview"` for host styling. `"pdf"` renders a plain `<div>` wrapper with no form — the layout used for print/PDF output.
131
- - `status` other than `"pending"` disables capture: the submit handler no-ops instead of POSTing.
182
+ `template` is the only required prop — everything else is optional, so a display-only instance is just `<Bonsai template={template} data={data} />`.
183
+
184
+ - `status` (default `"pending"`) and `expiry` (default `""`) seed the instance's store and are readable from bindings (`{{status}}`, `{{expiry}}`). In the browser they are read **once, on mount** — changing them later has no effect on a mounted instance (remount it with a new React `key` instead). `expiry` is informational only: Bonsai does not enforce it.
185
+
186
+ ### The `data` prop: seeded by reference
187
+
188
+ `data` is neither a fully controlled value nor a write-once default. It's read **by object identity**:
189
+
190
+ - **On mount**, the instance's store is seeded from a deep copy of `data` (`{}` when omitted).
191
+ - **User edits** mutate that internal copy only. Your `data` object is never written back to, so typing never produces a new `data` reference and can't feed a re-render loop.
192
+ - **When a different object is passed** (`Object.is(previous, next) === false`), the store's data is resynced to match the new object. This is the supported way to push fresh data into a mounted instance.
193
+ - **Passing the same object again is a no-op**, even if you mutated it in place. The mental model is: *pass a new object to force an update, don't mutate in place.*
194
+
195
+ This is not tick-by-tick two-way binding like a controlled `<input value>`; the instance reacts to reference changes, not to every store mutation.
196
+
197
+ > **Because identity is the signal, don't pass a freshly-built object literal on every render.** `data={{ profile }}` inline in JSX creates a new reference each time the parent re-renders, which resyncs the store and discards in-progress edits. Keep it in state, a module-level constant, or `useMemo`, and replace it deliberately when you want to push new data in.
198
+
199
+ `onDataChange(data)` fires with a fresh, read-only (frozen) snapshot whenever something inside the instance changes the store's data — a user edit or a `useUpdateData()` call. A parent-driven resync is deliberately *not* echoed back through it.
200
+
201
+ > **Never feed `onDataChange`'s output back into the `data` prop.** `onDataChange={setData}` paired with `data={data}` looks like the natural controlled-component wiring, but it isn't one:
202
+ >
203
+ > ```tsx
204
+ > // Don't do this:
205
+ > const [data, setData] = useState(initialData);
206
+ > <Bonsai template={template} data={data} onDataChange={setData} />
207
+ > ```
208
+ >
209
+ > Every keystroke fires `onDataChange`, which calls `setData`, which produces a *new* `data` reference on the next render — and since `data` is read by identity (see above), that reference change is indistinguishable from a host-initiated resync. The store gets resynced from data it just produced, on every keystroke, which can drop a fast second keystroke and defeats per-path reactivity: replacing the entire `data` object re-triggers every subscribed node instead of just the one that changed.
210
+ >
211
+ > `data` is for **host-initiated** updates only — loading a different record, resetting the form, undo/redo — deliberate, infrequent reference changes you make. `onDataChange` is for **observing** what the user typed — saving a draft, mirroring it into your own separate store, enabling a submit button — not for round-tripping back into `data`:
212
+ >
213
+ > ```tsx
214
+ > // Do this instead: observe without feeding back in.
215
+ > function ProfileForm({ initialData }: { initialData: Record<string, unknown> }) {
216
+ > const [draft, setDraft] = useState(initialData);
217
+ > return (
218
+ > <>
219
+ > <Bonsai
220
+ > template={template}
221
+ > data={initialData} // stable: only changes when you load a different record
222
+ > onDataChange={setDraft} // observe only — never passed back into `data`
223
+ > />
224
+ > <SaveDraftButton draft={draft} />
225
+ > </>
226
+ > );
227
+ > }
228
+ > ```
229
+
230
+ ### Submission
231
+
232
+ Submission only happens when the instance renders a `<form>` (see below) and the form is submitted — typically by a `button` with `type: "submit"`.
233
+
234
+ - `onSubmit(data)` — when supplied, it replaces the `captureUrl` POST entirely and receives a read-only snapshot of the current data. Throw (or reject) to signal failure.
235
+ - `captureUrl` — the compatibility path, used only when `onSubmit` is absent: the submit handler sends `fetch(captureUrl, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data }) })`. Cookies are sent (`credentials: "include"`). A network failure or a non-`2xx` response is a failure, thrown as `Error("Capture failed: <status> <statusText>")`. Use `onSubmit` if you need custom headers, a different body shape, or the response itself.
236
+ - With neither supplied, submitting is a clean no-op (the browser's default navigation is still prevented).
237
+ - While a submit is in flight, `{{submitting}}` is `true`.
238
+ - A successful submit through either path moves `status` to `"captured"`; a failure leaves it `"pending"` so the submit can be retried.
239
+ - `onError(error)` receives anything either path throws. Without it, the error propagates out of the submit handler.
240
+ - When `status` is anything other than `"pending"`, submitting sends nothing.
241
+
242
+ ### Wrapper element and render mode
243
+
244
+ - `render: "web"` (default) mounts the tree inside a `<form>`. `"preview"` renders the same form UI flagged with `data-mode="preview"` for host styling. `"pdf"` renders a plain `<div>` wrapper with no form — the layout used for print/PDF output.
245
+ - `as: "div"` renders the whole instance as a `<div>` instead of a `<form>` (and no submit handler is attached). Use it when embedding an instance inside a host `<form>`, or when rendering sibling/nested instances, where nested `<form>` elements would be invalid HTML.
246
+ - The two combine predictably: `render: "pdf"` always wins and produces a `<div>`; otherwise `as` decides.
247
+ - Components read the mode with `useBonsaiRenderMode()`. The built-in `signature` uses it: under `render: "pdf"` it shows the captured image (or an empty signature line) instead of the interactive pad.
132
248
 
133
249
  Each mounted instance owns its own store — there's no shared module-level state and no global `updateData`; `useUpdateData()` is the only way to reach a given instance's data, and only from within its subtree.
134
250
 
135
251
  ## Configuration schema
136
252
 
137
- The schema is defined in [`library/schemas/src/bonsai.schema.ts`](https://github.com/PebbleSoftwareInc/pebble-bonsai/blob/main/library/schemas/src/bonsai.schema.ts) and exported as `Config`.
253
+ The template format is defined by the Zod schema in [`library/schemas/src/bonsai.schema.ts`](https://github.com/PebbleSoftwareInc/pebble-bonsai/blob/main/library/schemas/src/bonsai.schema.ts), which is the source of truth for every component's allowed `args`.
138
254
 
139
255
  - **Node shape**: each node is either a string literal (rendered directly, and itself eligible for `{{ }}` bindings) or an object with:
140
- - `type`: a value from `COMPONENT` (see [Component reference](#component-reference)), including the structural `each` and `if` types (see [Structural nodes](#structural-nodes-each--if)).
141
- - `args`: props for the resolved React component. All `args` values are eligible for dynamic bindings.
142
- - `children`: an optional array of nested `Config` nodes.
143
- - **Validation**: `Config` is a strict, discriminated union with a tailored prop contract per component (e.g. inputs define `value`, `placeholder`, etc.) — unknown props on a known component type are rejected, not silently dropped. Invalid configs fail fast during parsing.
256
+ - `type`: a component type (see [Component reference](#component-reference)), including the structural `each` and `if` types (see [Structural nodes](#structural-nodes-each--if)).
257
+ - `args`: props for the resolved React component. Any `args` value can be a binding.
258
+ - `children`: an optional array of nested nodes.
144
259
  - **Conditional rendering** is a dedicated `if` node, not a generic `condition` prop on any component — see [Structural nodes](#structural-nodes-each--if).
145
260
 
261
+ **The renderer does not validate templates; it renders whatever it is given.** The schema is a discriminated union on `type` with a per-component `args` contract, and the Pebble Bonsai platform validates every template against it before rendering. At render time:
262
+
263
+ - An unknown `type` (one not in the active registry) follows the registry's `fallback`: a plain `<div>` by default (all built-in registries), a component of your choosing, or — with `fallback: "error"` — an error, which the node's error guard logs and renders as nothing.
264
+ - A node that throws while rendering is logged with `console.error` and rendered as nothing; the rest of the tree still renders.
265
+ - The exported `Config` type is `any`, so TypeScript won't catch template mistakes either.
266
+
267
+ ### Validating templates
268
+
269
+ Validation is a separate, explicit step. The root entry exports `validateTemplate(template, registry, options?)`, which checks a template against a registry — unknown types, each component's `args` against its `propsSchema`, whether it accepts children, and whether it supports the target `mode` (`"web"` or `"pdf"`):
270
+
271
+ ```ts
272
+ import { validateTemplate, pebbleRegistry } from "@pebble-software/bonsai";
273
+
274
+ const result = validateTemplate(template, pebbleRegistry, { mode: "pdf" });
275
+ if (!result.valid) console.error(result.errors); // [{ path, message, componentType? }]
276
+ ```
277
+
278
+ `pebbleRegistry` carries the platform schema's `args` contract for every built-in. `coreRegistry` and registries built with `createBonsaiRenderer` have no `propsSchema`s, so validating against them checks types and structure but not `args`. If your templates come from an untrusted source, validate them before rendering.
279
+
146
280
  Example node:
147
281
 
148
282
  ```json
@@ -161,11 +295,14 @@ Complete example templates live in the repo's [`examples/`](https://github.com/P
161
295
 
162
296
  ## Dynamic data binding
163
297
 
164
- - Use `{{ }}` to declare bindings. A binding must span the *entire* string value — `"{{a}}"` is evaluated, `"{{a}} of {{b}}"` is not (put each placeholder in its own child/string instead). Whitespace inside `{{ }}` is trimmed automatically.
298
+ - Use `{{ }}` to declare bindings. A value is a binding only when the *entire* string starts with `{{` and ends with `}}`; whitespace inside is trimmed.
299
+ - `"Hello {{name}}"` doesn't start with `{{`, so it renders literally, braces included.
300
+ - `"{{a}} of {{b}}"` starts and ends with braces but isn't one valid expression, so it fails to parse: it renders blank on the server and as the literal text in the browser. Don't rely on either.
301
+ - To mix text and values, put each part in its own child (`["{{a}}", " of ", "{{b}}"]`), or build the string in one expression (`"{{ concat(a, ' of ', b) }}"`).
165
302
  - Paths support dot notation (`user.email`) and array indexing (`addresses[0].street`).
166
303
  - Missing keys resolve to `undefined`; use the `default(value, fallback)` helper for a fallback inline, or handle defaults in host data.
167
304
  - Any `args` prop or string child can contain a binding.
168
- - Besides your own `data`, the instance's top-level state fields — `status`, `expiry`, and `submitting` — are readable from bindings too (e.g. an `if` on `{{submitting}}`).
305
+ - Besides your own `data`, the instance's top-level state fields — `status`, `expiry`, and `submitting` — are readable from bindings (e.g. an `if` on `{{submitting}}`). **These names are reserved:** a top-level `data` key called `status`, `expiry`, or `submitting` is shadowed by the state field and can't be read from a binding. Nest it (`{{order.status}}`) instead.
169
306
  - Inside an `each` row, an identifier resolves against the loop alias first (e.g. `applicant.name`), then falls back to the surrounding data for anything the alias doesn't shadow (e.g. `orgName` from outside the loop) — see [Structural nodes](#structural-nodes-each--if).
170
307
  - On the client, evaluating a node's bindings is also what subscribes that node to the store — see [Controlled inputs & state](#controlled-inputs--state).
171
308
 
@@ -182,9 +319,10 @@ Everything inside `{{ }}` is a small, sandboxed **formula language** — never e
182
319
  {{ "literal string" }} string literal ('single' or "double" quotes)
183
320
  {{ 42 }} {{ 3.14 }} number literal
184
321
  {{ true }} {{ false }} {{ null }}
322
+ {{ [a, b, 3] }} array literal
185
323
  ```
186
324
 
187
- Paths only ever read **own properties** of the data you pass in — `__proto__`, `constructor`, and `prototype` are always blocked, and inherited methods/fields are never visible to a template. A missing path evaluates to `undefined`, never throws.
325
+ Paths only ever read **own properties** of the data you pass in — `__proto__`, `constructor`, and `prototype` are always blocked, and inherited methods/fields are never visible to a template. (Own properties such as an array's or string's `length` do work.) A missing path evaluates to `undefined`, never throws.
188
326
 
189
327
  ### Operators
190
328
 
@@ -202,15 +340,15 @@ a ? b : c ternary
202
340
 
203
341
  ### Helpers
204
342
 
205
- Bare function calls resolve against a fixed, pure helper registry — anything not in the list below is a parse-time error, not a silent no-op:
343
+ Bare function calls resolve against a fixed, pure helper registry:
206
344
 
207
345
  | Helper | Signature | Notes |
208
346
  | --- | --- | --- |
209
- | `formatDate` | `formatDate(value, format?)` | `format` is a token string (`"YYYY-MM-DD"`, `"HH:mm"`, default `"YYYY-MM-DD"`) or a named style (`"short"`/`"medium"`/`"long"`/`"full"`). Always reads UTC fields, so SSR and the client render the same calendar date regardless of either machine's timezone. |
210
- | `formatCurrency` | `formatCurrency(value, currency?, locale?)` | Defaults `"USD"` / `"en-US"`. |
347
+ | `formatDate` | `formatDate(value, format?)` | `format` is a token string built from `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss` (default `"YYYY-MM-DD"`), or a named style (`"short"`/`"medium"`/`"long"`/`"full"`, always formatted in `en-US`). Always reads UTC fields, so SSR and the client render the same calendar date regardless of either machine's timezone. Invalid dates render `""`. |
348
+ | `formatCurrency` | `formatCurrency(value, currency?, locale?)` | Defaults `"USD"` / `"en-US"`. Non-numeric values format as `0`. |
211
349
  | `upper` / `lower` / `trim` | `(value)` | String utilities; nullish-safe. |
212
350
  | `concat` | `(...values)` | Joins with no separator, nullish-safe. |
213
- | `join` | `(array, separator?)` | Default separator `", "`. |
351
+ | `join` | `(array, separator?)` | Default separator `", "`; `""` for non-arrays. |
214
352
  | `default` | `(value, fallback)` | Falls back on `null`/`undefined`/`""` (not `0` or `false`). |
215
353
  | `length` | `(value)` | String or array length; `0` otherwise. |
216
354
  | `count` | `(array)` | Array length; `0` for non-arrays. |
@@ -218,7 +356,7 @@ Bare function calls resolve against a fixed, pure helper registry — anything n
218
356
  | `min` / `max` | `(...values)` | Accepts nested arrays or bare numbers. |
219
357
  | `round` | `(value, digits?)` | Default `0` digits. |
220
358
 
221
- A function reached any other way — `data.someField()`, `x.constructor()`, a helper name that isn't registered — is always a hard error rather than `undefined`, so a typo'd helper fails loudly instead of silently rendering blank.
359
+ Anything outside the allowed surface — a helper name that isn't registered, `data.someField()`, `x.constructor()`, an unsupported array method — is rejected, never executed. A rejected (or malformed) expression is logged with `console.error` and evaluates to `undefined`, so the binding renders blank. Watch the console while authoring templates: a typo'd helper shows up there, not as an exception.
222
360
 
223
361
  Every helper is **pure and synchronous** — no network calls, no `Date.now()`/`new Date()` (ambient clock reads), no randomness. SSR output is a pure function of `(template, data)`, so nothing a helper does can desync a server render from the client.
224
362
 
@@ -232,15 +370,16 @@ A fixed set of array methods accept an inline arrow-style callback:
232
370
  {{ applicants.every(a => a.verified) }}
233
371
  {{ applicants.find(a => a.age > 65) }}
234
372
  {{ applicants.map((a, i) => i) }} -- second param is the index
373
+ {{ join(applicants.map(a => a.name), " & ") }}
235
374
  ```
236
375
 
237
376
  Allowlisted methods: `map`, `filter`, `some`, `every`, `find`. Anything else — `.push()`, `.constructor()`, a method on a non-array value — is rejected before the receiver is even touched. The `a => …` syntax isn't compiled or `eval`'d into a real JavaScript function: the arrow body is walked by the same interpreter that evaluates the rest of the expression, with the parameter bound in a scope frame that falls back to the surrounding data for any other identifier the body references.
238
377
 
239
378
  ## Structural nodes: each & if
240
379
 
241
- `each` and `if` are structural node types — they control what renders, not what's rendered. Both are strict schemas: an `each`/`if` node with an unrecognized arg fails validation rather than silently ignoring it.
380
+ `each` and `if` are structural node types — they control what renders, not what's rendered. Neither introduces a wrapper element; their children render as siblings.
242
381
 
243
- **`if`** — renders its children when `condition` is truthy, nothing otherwise. No wrapper element is introduced; multiple children render as siblings.
382
+ **`if`** — renders its children when `condition` is truthy, nothing otherwise.
244
383
 
245
384
  ```json
246
385
  {
@@ -260,73 +399,217 @@ Allowlisted methods: `map`, `filter`, `some`, `every`, `find`. Anything else —
260
399
  }
261
400
  ```
262
401
 
263
- - `items` (required): a binding resolving to an array.
402
+ - `items` (required): a binding that resolves to an array — either a plain path (`{{applicants}}`, `{{order.lines}}`) or a computed expression (`{{applicants.filter(a => a.active)}}`).
264
403
  - `as` (required): the alias name each row's children use to refer to the current item (`applicant.name`, not `applicants[i].name`).
265
404
  - `index` (optional): an alias for the current loop index (a plain number, not a store path — an index has nothing to write back to).
266
- - `key` (optional): a binding evaluated per-row to key React's reconciliation (e.g. `{{applicant.id}}`). **Strongly recommended** whenever rows can be reordered or removed — without it, rows fall back to positional (array-index) identity, which can silently reuse a DOM/component instance for the wrong logical row after a reorder.
405
+ - `key` (optional): a binding evaluated per-row to key React's reconciliation (e.g. `{{applicant.id}}`). **Strongly recommended** whenever rows can be reordered or removed — without it, rows fall back to positional (array-index) identity, which can silently reuse a DOM/component instance for the wrong logical row after a reorder. Keys must be unique within the list.
267
406
  - Identifiers inside a row resolve against the alias first, then fall back to the surrounding data for anything the alias doesn't shadow — so `{{orgName}}` still works inside a loop over `applicants` even though `orgName` isn't a field on `applicant`.
268
- - Two-way binding inside a row (e.g. an `<input>` bound to `{{applicant.name}}`) writes back to the row's real store path (`applicants.<index>.name`), as long as `items` is a plain path binding. A computed/filtered array (`{{applicants.filter(...)}}`) has no 1:1 index correspondence back to the store, so rows render read-only in that case.
407
+ - Two-way binding inside a row (e.g. an `input` bound to `{{applicant.name}}`) writes back to the row's real store path (`applicants.<index>.name`) when `items` is a plain path. Rows of a computed `items` expression have no 1:1 mapping back to the store, so inputs in them show their value but are read-only (edits are ignored). To make a filtered list editable, loop over the full array and put an `if` inside the row (`"condition": "{{applicant.active}}"`).
269
408
  - `each` nodes can nest; each level's alias stacks on top of the outer one.
270
409
 
271
410
  ## Controlled inputs & state
272
411
 
273
412
  - Each mounted `Bonsai` instance gets its own `valtio` store — not a shared module-level singleton — holding `{ status, expiry, data, submitting }`. It's reachable only via React context, from components mounted inside that instance's subtree.
274
- - `useUpdateData()` (call it from inside the subtree) returns an `updateData(path, value)` function that mutates the store's `data` using the same path grammar as bindings.
275
- - Controlled components registered in `controlledInputMap` automatically wire up change handlers:
276
- - Value props point at a binding (e.g. `value: "{{profile.name}}"`).
277
- - Change events feed straight into `updateData` — no debounce, no local-echo state. Per-node subscriptions make every keystroke's store write and re-render cheap enough that this stays snappy, and it's what lets dependent expressions (`{{price*qty}}`, an `if` condition) update in the same event as the keystroke that changed them.
413
+ - `useUpdateData()` returns an `updateData(path, value)` function that mutates the store's `data` using the same path grammar as bindings (`"applicants.0.name"`, `"addresses[0].street"`). Call it from a custom component you've registered (see [Extending Bonsai](#extending-bonsai)). It only works in the client render: the server render has no store, so a component that calls `useUpdateData()` unconditionally will throw during SSR.
414
+ - Controlled components (see the list in [Component reference](#component-reference)) automatically wire up a value prop and change handler:
415
+ - Bind the value prop to a plain path (e.g. `value: "{{profile.name}}"`) — that path is where edits are written. A formula (`value: "{{first + ' ' + last}}"`) has no path to write to, so the input shows its result and is read-only.
416
+ - Change events feed straight into the store — no debounce, no local-echo state. Per-node subscriptions make every keystroke's store write and re-render cheap enough that this stays snappy, and it's what lets dependent expressions (`{{price * qty}}`, an `if` condition) update in the same event as the keystroke that changed them.
278
417
  - When something mutates the store's data, every node whose binding reads that path re-renders; unrelated nodes don't.
279
- - Non-controlled components simply receive evaluated props and behave as standard React elements.
280
- - Submission is handled internally: the client render mounts its own `<form>` and POSTs `{ data: store.data }` to `captureUrl` on submit.
281
- - **Reactivity model**: every leaf reads the store itself (via `valtio`'s `useSnapshot`) and evaluates its own bindings against that snapshot — the read *is* the subscription. A node with no bindings at all (a plain layout `<div>`) is detected and skipped from this entirely, so it never re-renders due to an unrelated store write. This is what keeps a keystroke in one field from re-rendering the rest of the form.
418
+ - Non-controlled components simply receive their evaluated `args` as props and behave as standard React elements.
419
+ - The store's `data` is deep-copied from the `data` prop *for plain objects and arrays* — an instance never mutates the object you passed in, and two instances seeded from the same plain-object `data` still get fully independent state. **Non-plain values nested inside `data` (class instances, `Map`/`Set`, functions, `Date`, `File`, etc.) are passed through by reference, not cloned.** If `data` nests something like `{ profile: new SomeClass() }`, that nested value is shared between every instance seeded from it, and writes through it are visible to the caller. Keep non-plain values out of `data` if you need per-instance isolation.
420
+ - **Reactivity model**: every leaf reads the store itself (via `valtio`'s `useSnapshot`) and evaluates its own bindings against that snapshot — the read *is* the subscription. A node with no bindings at all (a plain layout `div`) is detected and skipped from this entirely, so it never re-renders due to an unrelated store write. This is what keeps a keystroke in one field from re-rendering the rest of the form.
282
421
 
283
422
  ## Component reference
284
423
 
285
- The full, authoritative list of component types is the `COMPONENT` enum in `@bonsai/constants` ([`library/constants/src/components.ts`](https://github.com/PebbleSoftwareInc/pebble-bonsai/blob/main/library/constants/src/components.ts)) — `componentMap` (`src/lib/ui/registry.ts`) has an entry for every value in it.
424
+ The full list of built-in component types is the `COMPONENT` enum in [`library/constants/src/components.ts`](https://github.com/PebbleSoftwareInc/pebble-bonsai/blob/main/library/constants/src/components.ts); allowed `args` for each are in the [schema](#configuration-schema).
286
425
 
287
426
  | Category | Schema types |
288
427
  | --- | --- |
289
428
  | Structural (control what renders, not a real element) | `each`, `if` |
290
- | Plain elements | `div`, `span`, `a`, `p`, `h`, `ol`, `ul`, `li`, `image` |
429
+ | Plain elements | `div`, `span`, `a`, `p`, `h`, `ol`, `ul`, `li`, `image` (an `<img>`; renders nothing when `src` is empty) |
291
430
  | Layout / display | `card` (+ `card-header`, `card-title`, `card-description`, `card-content`, `card-footer`, `card-action`, `card-icon`), `accordion` (+ `accordion-item`, `accordion-trigger`, `accordion-content`), `collapsible` (+ `collapsible-trigger`, `collapsible-content`), `alert`, `avatar`, `badge`, `separator`, `skeleton`, `loader`, `progress` |
292
431
  | Tabular | `table` (+ `table-header`, `table-body`, `table-footer`, `table-head`, `table-row`, `table-cell`, `table-caption`) |
293
- | Interactive / controlled inputs | `input` (subtypes: `text`, `email`, `password`, `number`, and a `default` fallback), `textarea`, `checkbox`, `switch`, `otp`, `calendar`, `map`, `signature`, `input-group` (+ `input-group-text`, `input-group-addon`, `input-group-input`, `input-group-button`, `input-group-textarea`) |
432
+ | Inputs | `input` (`type`: `text` (default), `email`, `password`, `number`), `textarea`, `checkbox`, `switch`, `otp`, `input-group` (+ `input-group-text`, `input-group-addon`, `input-group-input`, `input-group-button`, `input-group-textarea`) |
433
+ | Heavy inputs (preset only) | `calendar`, `signature` |
294
434
  | Other | `button`, `label` |
295
435
 
296
- Controlled inputs (registered in `controlledInputMap` — `input`, `otp`, `checkbox`, `textarea`, `input-group-input`, `input-group-textarea`, `switch`, `signature`, `calendar`, `map`) automatically wire up a value prop and change handler as described in [Controlled inputs & state](#controlled-inputs--state). Everything else just receives its evaluated `args` as props.
436
+ **Controlled inputs** — `input`, `textarea`, `checkbox`, `switch`, `otp`, `input-group-input`, `input-group-textarea`, plus `calendar` and `signature` when their preset is registered — two-way bind as described in [Controlled inputs & state](#controlled-inputs--state):
297
437
 
298
- > Need a component that isn't listed? Add it to `COMPONENT`/`COMPONENT_LIST` in `@bonsai/constants`, add its prop contract to `@bonsai/schemas`, and register the React implementation in `componentMap` (and `controlledInputMap` if it should behave as a controlled input) — see [Extending Bonsai](#extending-bonsai).
438
+ | Type | Bound prop | Stored value |
439
+ | --- | --- | --- |
440
+ | `input`, `textarea`, `otp`, `input-group-input`, `input-group-textarea` | `value` | the input's string value; an `input` with `type: "number"` stores a number (or `""` while empty) |
441
+ | `checkbox`, `switch` | `checked` | `boolean` |
442
+ | `calendar` | `selected` | the selected date |
443
+ | `signature` | `value` | a data-URL image string, or `null` when cleared |
444
+
445
+ Everything else just receives its evaluated `args` as props.
446
+
447
+ **Heavy inputs** are not in the `/core` registry: with `@pebble-software/bonsai/core`, a `calendar` or `signature` node renders as a plain `<div>` until you add its preset (see [Extending Bonsai](#extending-bonsai)). The root entry includes both.
299
448
 
300
449
  ## Styling
301
450
 
302
- Bonsai is styled with **Tailwind CSS v4**. Components emit plain Tailwind utility classes, and templates can pass any additional utilities through `className` on any node.
451
+ Bonsai is styled with **Tailwind CSS v4**. Components emit plain Tailwind utility classes, and templates can pass additional utilities through `className` on any node.
452
+
453
+ ```css
454
+ /* your app's Tailwind v4 entry, e.g. app.css */
455
+ @import "@pebble-software/bonsai/index.css";
456
+ @source "./src"; /* your own templates/components */
457
+ ```
303
458
 
304
- - The design system lives in [`src/index.css`](https://github.com/PebbleSoftwareInc/pebble-bonsai/blob/main/apps/bonsai/src/index.css): an `@theme` token set (an OKLCH color palette with swappable accent colors, typography, shadows) plus base and utility layers, custom fonts (Sora, Fredoka, and Great Vibes for signatures) loaded via `@font-face`, and style overrides for the embedded map (MapLibre) and chart (Recharts) widgets.
305
- - Every rendered tree is wrapped in a `.bonsai-root` element carrying a `data-mode` attribute (`web` / `pdf` / `preview`), so hosts can scope their own overrides per render mode.
306
- - **Getting the stylesheet**: inside the Pebble Bonsai platform, CSS is compiled per-template at form-creation time — only the classes a template actually uses are emitted. When consuming this package standalone, compile the styles with your own Tailwind v4 build: import the Bonsai theme and add the package (and your template sources) as content via `@source` so the utilities your templates reference are generated. A prebuilt, ready-to-import stylesheet export is planned while the packaging story stabilizes during the preview period.
307
- - **Theming**: the theme is token-driven — redefine the CSS variables (e.g. `--color-main`, `--font-heading`) after the theme to restyle every component at once, or target `.bonsai-root` descendants for finer overrides.
459
+ - **Compile it with your own Tailwind v4 build.** `index.css` ships as uncompiled Tailwind source: it opens with `@import "tailwindcss"` and carries the full theme (an OKLCH color palette with swappable accent colors, typography, shadows) and base styles. Its `@source "./"` points Tailwind at Bonsai's compiled `dist/`, so every class the built-in components use is generated. `tailwindcss` and `tw-animate-css` are optional peers for this reason. None of the JavaScript entry points import it; it is the only side effect the package declares (`"sideEffects": ["**/*.css"]`), so bundlers can tree-shake everything else.
460
+ - **It styles the whole page, not just Bonsai.** The stylesheet includes Tailwind's Preflight reset plus base rules for `body` (font, background, text color) and `h1`–`h6` (heading font). On a page that has its own design system, load it deliberately — for example, only on the routes that render Bonsai.
461
+ - **Fonts load from Pebble's CDN.** Sora, Fredoka, and Great Vibes (used by the signature control) are declared with `@font-face` pointing at `https://brand.pebble.software/fonts/`. If you use a Content Security Policy, allow that origin in `font-src`, or override `--font-body`, `--font-heading`, and `--font-signature` with fonts you host.
462
+ - **Scoping**: every rendered tree is wrapped in a `.bonsai-root` element carrying a `data-mode` attribute (`web` / `pdf` / `preview`), so hosts can scope their own overrides per render mode.
463
+ - **Accent colors**: set `data-theme` on any ancestor (`<body data-theme="indigo">`) to switch the accent palette. Available values: `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald` (default), `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`.
464
+ - **Theming**: the theme is token-driven — redefine the CSS variables (e.g. `--color-main`, `--font-heading`) after the stylesheet to restyle every component at once, or target `.bonsai-root` descendants for finer overrides.
465
+ - **Utilities in your templates**: a `className` utility that no built-in component uses (say, `grid-cols-5`) is only generated if your build also scans the templates that use it — add their location with `@source`. Inside the Pebble Bonsai platform, CSS is compiled per template so this never comes up.
308
466
 
309
467
  ## Extending Bonsai
310
468
 
311
- 1. **Add a component**:
312
- - Extend `COMPONENT` and `COMPONENT_LIST` in `@bonsai/constants`.
313
- - Add prop validation in `@bonsai/schemas`.
314
- - Register the React implementation in `componentMap`.
315
- - Optionally register controlled behaviour in `controlledInputMap`.
316
- 2. **Custom data handling**:
317
- - Use `useUpdateData()` from a component mounted inside a `Bonsai` instance's subtree for simple mutations.
318
- 3. **Alternate styling**:
319
- - Redefine the theme's CSS variables before mounting Bonsai — see [Styling](#styling).
469
+ ### Add or replace components
470
+
471
+ `createBonsaiRenderer` builds a renderer whose registry is `options.registry` (the core set by default) plus whatever components you pass. Your entries win on a key collision, so you can add new types or swap out built-ins. Call it **once, at module scope** — each call returns a new component type, so calling it during render would remount the whole tree every time.
472
+
473
+ ```tsx
474
+ import { createBonsaiRenderer, useUpdateData } from "@pebble-software/bonsai/core";
475
+ import { calendarPreset } from "@pebble-software/bonsai/presets/calendar";
476
+
477
+ // A custom component receives the node's evaluated `args` as props.
478
+ function Rating({ value = 0, onChange }: { value?: number; onChange?: (next: number) => void }) {
479
+ return (
480
+ <div role="radiogroup" aria-label="Rating">
481
+ {[1, 2, 3, 4, 5].map((n) => (
482
+ <button key={n} type="button" aria-pressed={n <= value} onClick={() => onChange?.(n)}>
483
+ {n <= value ? "★" : "☆"}
484
+ </button>
485
+ ))}
486
+ </div>
487
+ );
488
+ }
489
+
490
+ // Custom components can also write to the instance's store directly.
491
+ // (Client render only — `useUpdateData` throws during SSR.)
492
+ function ClearButton({ path }: { path: string }) {
493
+ const updateData = useUpdateData();
494
+ return (
495
+ <button type="button" onClick={() => updateData(path, "")}>
496
+ Clear
497
+ </button>
498
+ );
499
+ }
500
+
501
+ export const { Bonsai } = createBonsaiRenderer({
502
+ components: {
503
+ ...calendarPreset.components,
504
+ rating: Rating,
505
+ "clear-button": ClearButton,
506
+ },
507
+ controlledInputs: {
508
+ ...calendarPreset.controlledInputs,
509
+ // `{ "type": "rating", "args": { "value": "{{review.stars}}" } }` now
510
+ // two-way binds `review.stars`.
511
+ rating: { valueProp: "value", changeProp: "onChange", extractor: (next) => next },
512
+ },
513
+ });
514
+ ```
515
+
516
+ A controlled-input entry names the prop that carries the value (`valueProp`), the prop that reports a change (`changeProp`), and how to turn whatever that change prop is called with into the value to store (`extractor`). An entry can also be a map of `args.type` → entry with a `default` fallback, as the built-in `input` uses.
517
+
518
+ Custom types aren't part of the platform schema, so templates that use them only render with your renderer.
519
+
520
+ ### Bring your own registry
521
+
522
+ For a component library of your own, declare a full `BonsaiRegistry` instead of layering onto the built-ins. Each entry can carry a `propsSchema` (used by `validateTemplate`), `capabilities`, and a `controlled` binding (`{ valueProp, changeProp, extract }`):
523
+
524
+ ```tsx
525
+ import { createBonsaiRenderer, defineBonsaiRegistry, defineComponent } from "@pebble-software/bonsai/core";
526
+ import { z } from "zod";
527
+
528
+ // `Rating` as defined in the previous example.
529
+ const acmeRegistry = defineBonsaiRegistry({
530
+ id: "acme-ui",
531
+ version: "1.0.0",
532
+ fallback: "error", // unknown types fail instead of rendering a <div>
533
+ components: {
534
+ rating: defineComponent({
535
+ component: Rating,
536
+ propsSchema: z.object({ value: z.string().optional() }),
537
+ capabilities: ["bindings", "formField", "ssr"],
538
+ controlled: { valueProp: "value", changeProp: "onChange", extract: (next) => next },
539
+ }),
540
+ },
541
+ });
542
+
543
+ export const { Bonsai } = createBonsaiRenderer({ registry: acmeRegistry });
544
+ ```
545
+
546
+ A `registry` prop on an individual `<Bonsai>` replaces the renderer's registry for that instance only. `extendRegistry(base, preset)` layers a preset-shaped `{ components, controlledInputs }` over any registry — `createBonsaiRenderer` uses it for its `components`/`controlledInputs` options.
547
+
548
+ ### Contributing a built-in component
549
+
550
+ To add a component to Bonsai itself (in this monorepo):
551
+
552
+ 1. Add it to `COMPONENT` in `library/constants/src/components.ts`.
553
+ 2. Add its node and `args` contract to `library/schemas/src/bonsai.schema.ts`, and list it in `PEBBLE_COMPONENT_SCHEMAS` there (that is where `pebbleRegistry` gets its `propsSchema`).
554
+ 3. Register the React implementation in `coreComponents` (`apps/bonsai/src/renderer/core-registry.ts`) — or, if it pulls in a heavy dependency, in a new preset under `apps/bonsai/src/renderer/presets/`, with a matching entry in `vite.config.ts` and `package.json#exports`.
555
+ 4. If it's an input, add its binding to `coreControlledInputs` (or the preset's `controlledInputs`).
556
+
557
+ ## Performance
558
+
559
+ - **Compilation is cached per template identity** — reusing the same `Config` object across many `Bonsai` instances (or across re-renders) compiles its expressions exactly once, no matter how many instances mount. Passing a fresh object (e.g. `structuredClone`d or freshly parsed) is a cache miss and compiles again, so parse a template once and keep the object — the same by-identity principle as [the `data` prop](#the-data-prop-seeded-by-reference).
560
+ - **A field update only re-evaluates its own binding** — thanks to per-node `useSnapshot` subscriptions (see [Controlled inputs & state](#controlled-inputs--state)), editing one row in an `each` list of any size re-evaluates only that row's bindings, not the whole list.
561
+ - **Long visible trees**: Bonsai renders every node in the template; it does not paginate or virtualize a long tree for you. For a very long visible page (a long `each` list, a long scrollable form), consider handling it at the host-application layer:
562
+ - CSS `content-visibility: auto` on off-screen sections/rows, so the browser skips layout and paint work for content that isn't visible yet.
563
+ - Pagination or "load more" — page the `data` array yourself (e.g. only pass the current page's `rows` into `data`) rather than rendering thousands of `each` rows at once.
564
+ - Virtualization (e.g. `react-window`/`react-virtual`) for read-only/display rows.
565
+
566
+ **Do not virtualize rows that contain form fields without preserving focus and input state.** A naive virtualized list unmounts off-screen DOM nodes as the user scrolls; if a row's `<input>` is focused or mid-keystroke when it scrolls out and gets unmounted, the focus and any in-progress keystroke are lost. If you virtualize a form with Bonsai-rendered rows, keep the underlying `data` (Bonsai's store) intact across virtualization and make sure the currently-focused row is never unmounted while the user is actively editing it.
567
+ - **Heavy controls are opt-in and lazy-loadable**: the calendar and signature controls are not part of the core bundle — `@pebble-software/bonsai/core` never pulls in `react-day-picker` or `react-signature-canvas` (see [Package exports](#package-exports)). Import only the preset(s) a given template actually uses, and if a screen only sometimes needs one, load it on demand with `React.lazy`:
568
+
569
+ ```tsx
570
+ import { lazy, Suspense } from "react";
571
+ import { createBonsaiRenderer, type Config } from "@pebble-software/bonsai/core";
572
+
573
+ // Module scope: the preset is fetched (and the renderer created) once, the
574
+ // first time a <SignatureBonsai> actually renders.
575
+ const SignatureBonsai = lazy(async () => {
576
+ const { signaturePreset } = await import("@pebble-software/bonsai/presets/signature");
577
+ return {
578
+ default: createBonsaiRenderer({
579
+ components: signaturePreset.components,
580
+ controlledInputs: signaturePreset.controlledInputs,
581
+ }).Bonsai,
582
+ };
583
+ });
584
+
585
+ export function SignStep({ template }: { template: Config }) {
586
+ return (
587
+ <Suspense fallback={<p>Loading signature pad…</p>}>
588
+ <SignatureBonsai template={template} />
589
+ </Suspense>
590
+ );
591
+ }
592
+ ```
593
+
594
+ This keeps the initial bundle to the core renderer (see the gzip budgets in [`scripts/check-bundle-size.mjs`](https://github.com/PebbleSoftwareInc/pebble-bonsai/blob/main/apps/bonsai/scripts/check-bundle-size.mjs)) and defers the cost of any heavy control until a screen that actually renders one is reached.
320
595
 
321
596
  ## Development
322
597
 
323
598
  Issues and pull requests are welcome at [PebbleSoftwareInc/pebble-bonsai](https://github.com/PebbleSoftwareInc/pebble-bonsai). This package lives at `apps/bonsai` in the monorepo.
324
599
 
600
+ ```bash
601
+ pnpm install
602
+ pnpm --filter @pebble-software/bonsai run build
603
+ ```
604
+
605
+ From `apps/bonsai`:
606
+
325
607
  ```bash
326
608
  pnpm lint # Static analysis using ESLint
327
609
  pnpm test # Run the test suite (vitest)
328
610
  pnpm type-check # TypeScript without emit
329
- pnpm build # Bundles dist/ (ESM + type declarations) via Vite
611
+ pnpm build # Bundles dist/ (ESM + type declarations) via Vite and copies the raw index.css theme
612
+ pnpm size-check # Checks each entry against its gzip budget (run after build)
330
613
  ```
331
614
 
332
- `dist/` contains the ESM bundle and TypeScript declarations consumed by downstream applications. The shared `@bonsai/constants` and `@bonsai/schemas` workspaces are bundled in, so the published package has no workspace dependencies.
615
+ `dist/` contains the ESM bundle, TypeScript declarations, and the uncompiled `index.css` theme consumed by downstream Tailwind builds. The shared `@bonsai/constants` and `@bonsai/schemas` workspaces are aliased to their sources and bundled in, so the published package has no workspace dependencies.