@pebble-software/bonsai 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +367 -88
- package/dist/chunks/button-CSLrIvsS.js +166 -0
- package/dist/chunks/button-CSLrIvsS.js.map +1 -0
- package/dist/chunks/create-renderer-1cky1uN9.js +280 -0
- package/dist/chunks/create-renderer-1cky1uN9.js.map +1 -0
- package/dist/chunks/render-shared-CIUUkLDn.js +2319 -0
- package/dist/chunks/render-shared-CIUUkLDn.js.map +1 -0
- package/dist/components/core/bonsai.d.ts +5 -0
- package/dist/components/core/render-shared.d.ts +123 -0
- package/dist/components/core/render-tree-client.d.ts +4 -0
- package/dist/components/core/render-tree-server.d.ts +2 -0
- package/dist/components/ui/a.d.ts +3 -0
- package/dist/components/ui/accordion.d.ts +17 -0
- package/dist/components/ui/alert-dialog.d.ts +23 -0
- package/dist/components/ui/alert.d.ts +12 -0
- package/dist/components/ui/avatar.d.ts +21 -0
- package/dist/components/ui/badge.d.ts +10 -0
- package/dist/components/ui/breadcrumb.d.ts +10 -0
- package/dist/components/ui/button.d.ts +14 -0
- package/dist/components/ui/calendar.d.ts +9 -0
- package/dist/components/ui/card.d.ts +15 -0
- package/dist/components/ui/checkbox.d.ts +7 -0
- package/dist/components/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/combobox.d.ts +31 -0
- package/dist/components/ui/command.d.ts +23 -0
- package/dist/components/ui/dialog.d.ts +23 -0
- package/dist/components/ui/drawer.d.ts +22 -0
- package/dist/components/ui/dropdown-menu.d.ts +29 -0
- package/dist/components/ui/form.d.ts +24 -0
- package/dist/components/ui/h.d.ts +10 -0
- package/dist/components/ui/input-group.d.ts +18 -0
- package/dist/components/ui/input-otp.d.ts +11 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/label.d.ts +10 -0
- package/dist/components/ui/loader.d.ts +14 -0
- package/dist/components/ui/p.d.ts +11 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/progress.d.ts +31 -0
- package/dist/components/ui/scroll-area.d.ts +13 -0
- package/dist/components/ui/select.d.ts +17 -0
- package/dist/components/ui/separator.d.ts +8 -0
- package/dist/components/ui/sidebar.d.ts +70 -0
- package/dist/components/ui/signature-pad.d.ts +7 -0
- package/dist/components/ui/skeleton.d.ts +7 -0
- package/dist/components/ui/switch.d.ts +8 -0
- package/dist/components/ui/table.d.ts +14 -0
- package/dist/components/ui/tabs.d.ts +15 -0
- package/dist/components/ui/textarea.d.ts +5 -0
- package/dist/components/ui/toast.d.ts +46 -0
- package/dist/components/ui/tooltip.d.ts +15 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.js +17 -0
- package/dist/core.js.map +1 -0
- package/dist/hooks/haptics.d.ts +8 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/index.css +7 -0
- package/dist/index.d.ts +27 -338
- package/dist/index.js +1157 -4760
- package/dist/index.js.map +1 -0
- package/dist/lib/ast/cache.d.ts +8 -0
- package/dist/lib/ast/evaluator.d.ts +2 -0
- package/dist/lib/ast/helpers.d.ts +2 -0
- package/dist/lib/ast/index.d.ts +29 -0
- package/dist/lib/ast/setup.d.ts +2 -0
- package/dist/lib/ast/types.d.ts +1 -0
- package/dist/lib/ast/utils.d.ts +4 -0
- package/dist/lib/haptics.d.ts +8 -0
- package/dist/lib/registry/drift.d.ts +17 -0
- package/dist/lib/registry/identity.d.ts +24 -0
- package/dist/lib/registry/json-schema-to-zod.d.ts +32 -0
- package/dist/lib/registry/manifest-schema.d.ts +62 -0
- package/dist/lib/registry/manifest.d.ts +26 -0
- package/dist/lib/registry/merge-generated.d.ts +7 -0
- package/dist/lib/registry/pebble-registry.d.ts +1 -0
- package/dist/lib/registry/types.d.ts +29 -0
- package/dist/lib/registry/validate.d.ts +18 -0
- package/dist/lib/renderer/compile-template.d.ts +70 -0
- package/dist/lib/ui/registry.d.ts +19 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/presets/calendar.js +373 -0
- package/dist/presets/calendar.js.map +1 -0
- package/dist/presets/signature.js +404 -0
- package/dist/presets/signature.js.map +1 -0
- package/dist/renderer/core-registry.d.ts +52 -0
- package/dist/renderer/create-renderer.d.ts +38 -0
- package/dist/renderer/presets/calendar.d.ts +5 -0
- package/dist/renderer/presets/signature.d.ts +6 -0
- package/dist/types.d.ts +1 -0
- package/package.json +47 -21
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
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
-
|
|
53
|
+
**Batteries-included** (`@pebble-software/bonsai`) — the root entry imports every preset, so all of the optional peers must be installed:
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
59
|
+
Optional peer dependencies, by preset:
|
|
56
60
|
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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,187 @@ const template: Config = {
|
|
|
79
92
|
},
|
|
80
93
|
{
|
|
81
94
|
type: "input",
|
|
82
|
-
args: { id: "name",
|
|
95
|
+
args: { id: "name", value: "{{profile.name}}" },
|
|
83
96
|
},
|
|
84
97
|
{
|
|
85
98
|
type: "button",
|
|
86
|
-
args: {
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
109
|
-
| --- | --- |
|
|
110
|
-
| `Bonsai`
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
|
|
140
|
+
Named exports of `@pebble-software/bonsai` and `@pebble-software/bonsai/core`:
|
|
141
|
+
|
|
142
|
+
| Export | Entry | Kind | Description |
|
|
143
|
+
| --- | --- | --- | --- |
|
|
144
|
+
| `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`). |
|
|
145
|
+
| `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). |
|
|
146
|
+
| `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). |
|
|
147
|
+
| `pebbleRegistry` | root | object | The full built-in `BonsaiRegistry` (core + every preset, with each component's `propsSchema`). |
|
|
148
|
+
| `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. |
|
|
149
|
+
| `coreRegistry` | both | object | The core built-in `BonsaiRegistry` (no calendar or signature). |
|
|
150
|
+
| `coreComponents`, `coreControlledInputs` | `/core` | object | The core built-ins as plain component / controlled-input maps. |
|
|
151
|
+
| `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={…} />`. |
|
|
152
|
+
| `useBonsaiRenderMode()` | both | hook | The nearest `Bonsai` root's `render` mode (`web` / `pdf` / `preview`), for components that render differently in a PDF. |
|
|
153
|
+
| `Config` | both | type | A template node. Currently typed as `any` — see [Configuration schema](#configuration-schema). |
|
|
154
|
+
| `BonsaiProps` | both | type | Props of `Bonsai`. |
|
|
155
|
+
| `BonsaiRendererOptions`, `BonsaiRendererInstance` | both | type | Argument and return type of `createBonsaiRenderer`. |
|
|
156
|
+
| `BonsaiRegistry`, `ComponentDefinition`, `ControlledFieldSpec`, `ComponentCapability` | both | type | The registry the renderer resolves component types against. |
|
|
157
|
+
| `BonsaiPreset`, `ComponentRegistry`, `ControlledInput`, `ControlledInputEntry`, `ControlledInputRegistry` | both | type | Preset shapes, for building your own presets. |
|
|
158
|
+
| `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). |
|
|
159
|
+
|
|
160
|
+
## The `Bonsai` component
|
|
118
161
|
|
|
119
162
|
```ts
|
|
120
163
|
interface BonsaiProps {
|
|
121
164
|
template: Config;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
165
|
+
data?: Record<string, unknown>;
|
|
166
|
+
onDataChange?: (data: Record<string, unknown>) => void;
|
|
167
|
+
onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
168
|
+
onError?: (error: unknown) => void;
|
|
169
|
+
captureUrl?: string;
|
|
170
|
+
expiry?: string;
|
|
125
171
|
status?: "pending" | "captured" | "completed" | "error" | "revoked";
|
|
126
172
|
render?: "web" | "pdf" | "preview";
|
|
173
|
+
as?: "form" | "div";
|
|
174
|
+
registry?: BonsaiRegistry; // overrides the renderer's registry for this instance
|
|
127
175
|
}
|
|
128
176
|
```
|
|
129
177
|
|
|
130
|
-
|
|
131
|
-
|
|
178
|
+
`template` is the only required prop — everything else is optional, so a display-only instance is just `<Bonsai template={template} data={data} />`.
|
|
179
|
+
|
|
180
|
+
- `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.
|
|
181
|
+
|
|
182
|
+
### The `data` prop: seeded by reference
|
|
183
|
+
|
|
184
|
+
`data` is neither a fully controlled value nor a write-once default. It's read **by object identity**:
|
|
185
|
+
|
|
186
|
+
- **On mount**, the instance's store is seeded from a deep copy of `data` (`{}` when omitted).
|
|
187
|
+
- **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.
|
|
188
|
+
- **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.
|
|
189
|
+
- **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.*
|
|
190
|
+
|
|
191
|
+
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.
|
|
192
|
+
|
|
193
|
+
> **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.
|
|
194
|
+
|
|
195
|
+
`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.
|
|
196
|
+
|
|
197
|
+
> **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:
|
|
198
|
+
>
|
|
199
|
+
> ```tsx
|
|
200
|
+
> // Don't do this:
|
|
201
|
+
> const [data, setData] = useState(initialData);
|
|
202
|
+
> <Bonsai template={template} data={data} onDataChange={setData} />
|
|
203
|
+
> ```
|
|
204
|
+
>
|
|
205
|
+
> 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.
|
|
206
|
+
>
|
|
207
|
+
> `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`:
|
|
208
|
+
>
|
|
209
|
+
> ```tsx
|
|
210
|
+
> // Do this instead: observe without feeding back in.
|
|
211
|
+
> function ProfileForm({ initialData }: { initialData: Record<string, unknown> }) {
|
|
212
|
+
> const [draft, setDraft] = useState(initialData);
|
|
213
|
+
> return (
|
|
214
|
+
> <>
|
|
215
|
+
> <Bonsai
|
|
216
|
+
> template={template}
|
|
217
|
+
> data={initialData} // stable: only changes when you load a different record
|
|
218
|
+
> onDataChange={setDraft} // observe only — never passed back into `data`
|
|
219
|
+
> />
|
|
220
|
+
> <SaveDraftButton draft={draft} />
|
|
221
|
+
> </>
|
|
222
|
+
> );
|
|
223
|
+
> }
|
|
224
|
+
> ```
|
|
225
|
+
|
|
226
|
+
### Submission
|
|
227
|
+
|
|
228
|
+
Submission only happens when the instance renders a `<form>` (see below) and the form is submitted — typically by a `button` with `type: "submit"`.
|
|
229
|
+
|
|
230
|
+
- `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.
|
|
231
|
+
- `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.
|
|
232
|
+
- With neither supplied, submitting is a clean no-op (the browser's default navigation is still prevented).
|
|
233
|
+
- While a submit is in flight, `{{submitting}}` is `true`.
|
|
234
|
+
- A successful submit through either path moves `status` to `"captured"`; a failure leaves it `"pending"` so the submit can be retried.
|
|
235
|
+
- `onError(error)` receives anything either path throws. Without it, the error propagates out of the submit handler.
|
|
236
|
+
- When `status` is anything other than `"pending"`, submitting sends nothing.
|
|
237
|
+
|
|
238
|
+
### Wrapper element and render mode
|
|
239
|
+
|
|
240
|
+
- `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.
|
|
241
|
+
- `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.
|
|
242
|
+
- The two combine predictably: `render: "pdf"` always wins and produces a `<div>`; otherwise `as` decides.
|
|
243
|
+
- 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
244
|
|
|
133
245
|
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
246
|
|
|
135
247
|
## Configuration schema
|
|
136
248
|
|
|
137
|
-
The
|
|
249
|
+
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
250
|
|
|
139
251
|
- **Node shape**: each node is either a string literal (rendered directly, and itself eligible for `{{ }}` bindings) or an object with:
|
|
140
|
-
- `type`: a
|
|
141
|
-
- `args`: props for the resolved React component.
|
|
142
|
-
- `children`: an optional array of nested
|
|
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.
|
|
252
|
+
- `type`: a component type (see [Component reference](#component-reference)), including the structural `each` and `if` types (see [Structural nodes](#structural-nodes-each--if)).
|
|
253
|
+
- `args`: props for the resolved React component. Any `args` value can be a binding.
|
|
254
|
+
- `children`: an optional array of nested nodes.
|
|
144
255
|
- **Conditional rendering** is a dedicated `if` node, not a generic `condition` prop on any component — see [Structural nodes](#structural-nodes-each--if).
|
|
145
256
|
|
|
257
|
+
**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:
|
|
258
|
+
|
|
259
|
+
- 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.
|
|
260
|
+
- A node that throws while rendering is logged with `console.error` and rendered as nothing; the rest of the tree still renders.
|
|
261
|
+
- The exported `Config` type is `any`, so TypeScript won't catch template mistakes either.
|
|
262
|
+
|
|
263
|
+
### Validating templates
|
|
264
|
+
|
|
265
|
+
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"`):
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { validateTemplate, pebbleRegistry } from "@pebble-software/bonsai";
|
|
269
|
+
|
|
270
|
+
const result = validateTemplate(template, pebbleRegistry, { mode: "pdf" });
|
|
271
|
+
if (!result.valid) console.error(result.errors); // [{ path, message, componentType? }]
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`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.
|
|
275
|
+
|
|
146
276
|
Example node:
|
|
147
277
|
|
|
148
278
|
```json
|
|
@@ -161,11 +291,14 @@ Complete example templates live in the repo's [`examples/`](https://github.com/P
|
|
|
161
291
|
|
|
162
292
|
## Dynamic data binding
|
|
163
293
|
|
|
164
|
-
- Use `{{ }}` to declare bindings. A binding
|
|
294
|
+
- Use `{{ }}` to declare bindings. A value is a binding only when the *entire* string starts with `{{` and ends with `}}`; whitespace inside is trimmed.
|
|
295
|
+
- `"Hello {{name}}"` doesn't start with `{{`, so it renders literally, braces included.
|
|
296
|
+
- `"{{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.
|
|
297
|
+
- 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
298
|
- Paths support dot notation (`user.email`) and array indexing (`addresses[0].street`).
|
|
166
299
|
- Missing keys resolve to `undefined`; use the `default(value, fallback)` helper for a fallback inline, or handle defaults in host data.
|
|
167
300
|
- 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
|
|
301
|
+
- 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
302
|
- 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
303
|
- 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
304
|
|
|
@@ -182,9 +315,10 @@ Everything inside `{{ }}` is a small, sandboxed **formula language** — never e
|
|
|
182
315
|
{{ "literal string" }} string literal ('single' or "double" quotes)
|
|
183
316
|
{{ 42 }} {{ 3.14 }} number literal
|
|
184
317
|
{{ true }} {{ false }} {{ null }}
|
|
318
|
+
{{ [a, b, 3] }} array literal
|
|
185
319
|
```
|
|
186
320
|
|
|
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.
|
|
321
|
+
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
322
|
|
|
189
323
|
### Operators
|
|
190
324
|
|
|
@@ -202,15 +336,15 @@ a ? b : c ternary
|
|
|
202
336
|
|
|
203
337
|
### Helpers
|
|
204
338
|
|
|
205
|
-
Bare function calls resolve against a fixed, pure helper registry
|
|
339
|
+
Bare function calls resolve against a fixed, pure helper registry:
|
|
206
340
|
|
|
207
341
|
| Helper | Signature | Notes |
|
|
208
342
|
| --- | --- | --- |
|
|
209
|
-
| `formatDate` | `formatDate(value, format?)` | `format` is a token string
|
|
210
|
-
| `formatCurrency` | `formatCurrency(value, currency?, locale?)` | Defaults `"USD"` / `"en-US"`. |
|
|
343
|
+
| `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 `""`. |
|
|
344
|
+
| `formatCurrency` | `formatCurrency(value, currency?, locale?)` | Defaults `"USD"` / `"en-US"`. Non-numeric values format as `0`. |
|
|
211
345
|
| `upper` / `lower` / `trim` | `(value)` | String utilities; nullish-safe. |
|
|
212
346
|
| `concat` | `(...values)` | Joins with no separator, nullish-safe. |
|
|
213
|
-
| `join` | `(array, separator?)` | Default separator `", "
|
|
347
|
+
| `join` | `(array, separator?)` | Default separator `", "`; `""` for non-arrays. |
|
|
214
348
|
| `default` | `(value, fallback)` | Falls back on `null`/`undefined`/`""` (not `0` or `false`). |
|
|
215
349
|
| `length` | `(value)` | String or array length; `0` otherwise. |
|
|
216
350
|
| `count` | `(array)` | Array length; `0` for non-arrays. |
|
|
@@ -218,7 +352,7 @@ Bare function calls resolve against a fixed, pure helper registry — anything n
|
|
|
218
352
|
| `min` / `max` | `(...values)` | Accepts nested arrays or bare numbers. |
|
|
219
353
|
| `round` | `(value, digits?)` | Default `0` digits. |
|
|
220
354
|
|
|
221
|
-
|
|
355
|
+
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
356
|
|
|
223
357
|
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
358
|
|
|
@@ -232,15 +366,16 @@ A fixed set of array methods accept an inline arrow-style callback:
|
|
|
232
366
|
{{ applicants.every(a => a.verified) }}
|
|
233
367
|
{{ applicants.find(a => a.age > 65) }}
|
|
234
368
|
{{ applicants.map((a, i) => i) }} -- second param is the index
|
|
369
|
+
{{ join(applicants.map(a => a.name), " & ") }}
|
|
235
370
|
```
|
|
236
371
|
|
|
237
372
|
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
373
|
|
|
239
374
|
## Structural nodes: each & if
|
|
240
375
|
|
|
241
|
-
`each` and `if` are structural node types — they control what renders, not what's rendered.
|
|
376
|
+
`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
377
|
|
|
243
|
-
**`if`** — renders its children when `condition` is truthy, nothing otherwise.
|
|
378
|
+
**`if`** — renders its children when `condition` is truthy, nothing otherwise.
|
|
244
379
|
|
|
245
380
|
```json
|
|
246
381
|
{
|
|
@@ -260,73 +395,217 @@ Allowlisted methods: `map`, `filter`, `some`, `every`, `find`. Anything else —
|
|
|
260
395
|
}
|
|
261
396
|
```
|
|
262
397
|
|
|
263
|
-
- `items` (required): a binding
|
|
398
|
+
- `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
399
|
- `as` (required): the alias name each row's children use to refer to the current item (`applicant.name`, not `applicants[i].name`).
|
|
265
400
|
- `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.
|
|
401
|
+
- `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
402
|
- 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
|
|
403
|
+
- 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
404
|
- `each` nodes can nest; each level's alias stacks on top of the outer one.
|
|
270
405
|
|
|
271
406
|
## Controlled inputs & state
|
|
272
407
|
|
|
273
408
|
- 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()`
|
|
275
|
-
- Controlled components
|
|
276
|
-
-
|
|
277
|
-
- Change events feed straight into
|
|
409
|
+
- `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.
|
|
410
|
+
- Controlled components (see the list in [Component reference](#component-reference)) automatically wire up a value prop and change handler:
|
|
411
|
+
- 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.
|
|
412
|
+
- 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
413
|
- 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
|
-
-
|
|
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
|
|
414
|
+
- Non-controlled components simply receive their evaluated `args` as props and behave as standard React elements.
|
|
415
|
+
- 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.
|
|
416
|
+
- **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
417
|
|
|
283
418
|
## Component reference
|
|
284
419
|
|
|
285
|
-
The full
|
|
420
|
+
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
421
|
|
|
287
422
|
| Category | Schema types |
|
|
288
423
|
| --- | --- |
|
|
289
424
|
| Structural (control what renders, not a real element) | `each`, `if` |
|
|
290
|
-
| Plain elements | `div`, `span`, `a`, `p`, `h`, `ol`, `ul`, `li`, `image` |
|
|
425
|
+
| Plain elements | `div`, `span`, `a`, `p`, `h`, `ol`, `ul`, `li`, `image` (an `<img>`; renders nothing when `src` is empty) |
|
|
291
426
|
| 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
427
|
| Tabular | `table` (+ `table-header`, `table-body`, `table-footer`, `table-head`, `table-row`, `table-cell`, `table-caption`) |
|
|
293
|
-
|
|
|
428
|
+
| 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`) |
|
|
429
|
+
| Heavy inputs (preset only) | `calendar`, `signature` |
|
|
294
430
|
| Other | `button`, `label` |
|
|
295
431
|
|
|
296
|
-
Controlled inputs
|
|
432
|
+
**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
433
|
|
|
298
|
-
|
|
434
|
+
| Type | Bound prop | Stored value |
|
|
435
|
+
| --- | --- | --- |
|
|
436
|
+
| `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) |
|
|
437
|
+
| `checkbox`, `switch` | `checked` | `boolean` |
|
|
438
|
+
| `calendar` | `selected` | the selected date |
|
|
439
|
+
| `signature` | `value` | a data-URL image string, or `null` when cleared |
|
|
440
|
+
|
|
441
|
+
Everything else just receives its evaluated `args` as props.
|
|
442
|
+
|
|
443
|
+
**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
444
|
|
|
300
445
|
## Styling
|
|
301
446
|
|
|
302
|
-
Bonsai is styled with **Tailwind CSS v4**. Components emit plain Tailwind utility classes, and templates can pass
|
|
447
|
+
Bonsai is styled with **Tailwind CSS v4**. Components emit plain Tailwind utility classes, and templates can pass additional utilities through `className` on any node.
|
|
448
|
+
|
|
449
|
+
```css
|
|
450
|
+
/* your app's Tailwind v4 entry, e.g. app.css */
|
|
451
|
+
@import "@pebble-software/bonsai/index.css";
|
|
452
|
+
@source "./src"; /* your own templates/components */
|
|
453
|
+
```
|
|
303
454
|
|
|
304
|
-
-
|
|
305
|
-
-
|
|
306
|
-
- **
|
|
307
|
-
- **
|
|
455
|
+
- **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.
|
|
456
|
+
- **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.
|
|
457
|
+
- **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.
|
|
458
|
+
- **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.
|
|
459
|
+
- **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`.
|
|
460
|
+
- **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.
|
|
461
|
+
- **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
462
|
|
|
309
463
|
## Extending Bonsai
|
|
310
464
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
465
|
+
### Add or replace components
|
|
466
|
+
|
|
467
|
+
`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.
|
|
468
|
+
|
|
469
|
+
```tsx
|
|
470
|
+
import { createBonsaiRenderer, useUpdateData } from "@pebble-software/bonsai/core";
|
|
471
|
+
import { calendarPreset } from "@pebble-software/bonsai/presets/calendar";
|
|
472
|
+
|
|
473
|
+
// A custom component receives the node's evaluated `args` as props.
|
|
474
|
+
function Rating({ value = 0, onChange }: { value?: number; onChange?: (next: number) => void }) {
|
|
475
|
+
return (
|
|
476
|
+
<div role="radiogroup" aria-label="Rating">
|
|
477
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
478
|
+
<button key={n} type="button" aria-pressed={n <= value} onClick={() => onChange?.(n)}>
|
|
479
|
+
{n <= value ? "★" : "☆"}
|
|
480
|
+
</button>
|
|
481
|
+
))}
|
|
482
|
+
</div>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Custom components can also write to the instance's store directly.
|
|
487
|
+
// (Client render only — `useUpdateData` throws during SSR.)
|
|
488
|
+
function ClearButton({ path }: { path: string }) {
|
|
489
|
+
const updateData = useUpdateData();
|
|
490
|
+
return (
|
|
491
|
+
<button type="button" onClick={() => updateData(path, "")}>
|
|
492
|
+
Clear
|
|
493
|
+
</button>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export const { Bonsai } = createBonsaiRenderer({
|
|
498
|
+
components: {
|
|
499
|
+
...calendarPreset.components,
|
|
500
|
+
rating: Rating,
|
|
501
|
+
"clear-button": ClearButton,
|
|
502
|
+
},
|
|
503
|
+
controlledInputs: {
|
|
504
|
+
...calendarPreset.controlledInputs,
|
|
505
|
+
// `{ "type": "rating", "args": { "value": "{{review.stars}}" } }` now
|
|
506
|
+
// two-way binds `review.stars`.
|
|
507
|
+
rating: { valueProp: "value", changeProp: "onChange", extractor: (next) => next },
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
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.
|
|
513
|
+
|
|
514
|
+
Custom types aren't part of the platform schema, so templates that use them only render with your renderer.
|
|
515
|
+
|
|
516
|
+
### Bring your own registry
|
|
517
|
+
|
|
518
|
+
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 }`):
|
|
519
|
+
|
|
520
|
+
```tsx
|
|
521
|
+
import { createBonsaiRenderer, defineBonsaiRegistry, defineComponent } from "@pebble-software/bonsai/core";
|
|
522
|
+
import { z } from "zod";
|
|
523
|
+
|
|
524
|
+
// `Rating` as defined in the previous example.
|
|
525
|
+
const acmeRegistry = defineBonsaiRegistry({
|
|
526
|
+
id: "acme-ui",
|
|
527
|
+
version: "1.0.0",
|
|
528
|
+
fallback: "error", // unknown types fail instead of rendering a <div>
|
|
529
|
+
components: {
|
|
530
|
+
rating: defineComponent({
|
|
531
|
+
component: Rating,
|
|
532
|
+
propsSchema: z.object({ value: z.string().optional() }),
|
|
533
|
+
capabilities: ["bindings", "formField", "ssr"],
|
|
534
|
+
controlled: { valueProp: "value", changeProp: "onChange", extract: (next) => next },
|
|
535
|
+
}),
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
export const { Bonsai } = createBonsaiRenderer({ registry: acmeRegistry });
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
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.
|
|
543
|
+
|
|
544
|
+
### Contributing a built-in component
|
|
545
|
+
|
|
546
|
+
To add a component to Bonsai itself (in this monorepo):
|
|
547
|
+
|
|
548
|
+
1. Add it to `COMPONENT` in `library/constants/src/components.ts`.
|
|
549
|
+
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`).
|
|
550
|
+
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`.
|
|
551
|
+
4. If it's an input, add its binding to `coreControlledInputs` (or the preset's `controlledInputs`).
|
|
552
|
+
|
|
553
|
+
## Performance
|
|
554
|
+
|
|
555
|
+
- **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).
|
|
556
|
+
- **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.
|
|
557
|
+
- **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:
|
|
558
|
+
- CSS `content-visibility: auto` on off-screen sections/rows, so the browser skips layout and paint work for content that isn't visible yet.
|
|
559
|
+
- 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.
|
|
560
|
+
- Virtualization (e.g. `react-window`/`react-virtual`) for read-only/display rows.
|
|
561
|
+
|
|
562
|
+
**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.
|
|
563
|
+
- **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`:
|
|
564
|
+
|
|
565
|
+
```tsx
|
|
566
|
+
import { lazy, Suspense } from "react";
|
|
567
|
+
import { createBonsaiRenderer, type Config } from "@pebble-software/bonsai/core";
|
|
568
|
+
|
|
569
|
+
// Module scope: the preset is fetched (and the renderer created) once, the
|
|
570
|
+
// first time a <SignatureBonsai> actually renders.
|
|
571
|
+
const SignatureBonsai = lazy(async () => {
|
|
572
|
+
const { signaturePreset } = await import("@pebble-software/bonsai/presets/signature");
|
|
573
|
+
return {
|
|
574
|
+
default: createBonsaiRenderer({
|
|
575
|
+
components: signaturePreset.components,
|
|
576
|
+
controlledInputs: signaturePreset.controlledInputs,
|
|
577
|
+
}).Bonsai,
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
export function SignStep({ template }: { template: Config }) {
|
|
582
|
+
return (
|
|
583
|
+
<Suspense fallback={<p>Loading signature pad…</p>}>
|
|
584
|
+
<SignatureBonsai template={template} />
|
|
585
|
+
</Suspense>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
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
591
|
|
|
321
592
|
## Development
|
|
322
593
|
|
|
323
594
|
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
595
|
|
|
596
|
+
```bash
|
|
597
|
+
pnpm install
|
|
598
|
+
pnpm --filter @pebble-software/bonsai run build
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
From `apps/bonsai`:
|
|
602
|
+
|
|
325
603
|
```bash
|
|
326
604
|
pnpm lint # Static analysis using ESLint
|
|
327
605
|
pnpm test # Run the test suite (vitest)
|
|
328
606
|
pnpm type-check # TypeScript without emit
|
|
329
|
-
pnpm build # Bundles dist/ (ESM + type declarations) via Vite
|
|
607
|
+
pnpm build # Bundles dist/ (ESM + type declarations) via Vite and copies the raw index.css theme
|
|
608
|
+
pnpm size-check # Checks each entry against its gzip budget (run after build)
|
|
330
609
|
```
|
|
331
610
|
|
|
332
|
-
`dist/` contains the ESM bundle
|
|
611
|
+
`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.
|