@pebble-software/bonsai
v0.0.8
Published
[](https://www.npmjs.com/package/@pebble-software/bonsai) [](https://github.com/PebbleSoftwareInc/pebble-bonsai)
Readme
@pebble-software/bonsai
⚠️ 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!
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.
Bonsai is the rendering engine behind 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).
Table of contents
- Features
- Installation
- Quick start
- Package exports
- Configuration schema
- Dynamic data binding
- Expression language
- Structural nodes: each & if
- Controlled inputs & state
- Component reference
- Styling
- Extending Bonsai
- Development
Features
- Schema-driven UI — every screen is a Zod-validated
Configtree, so the renderer only ever processes known-safe structures. - Universal rendering — one component (
Bonsai) picks server (SSR) or client (interactive) rendering automatically based on environment, withweb,pdf, andpreviewrender modes. - Composable components — schema component identifiers map to real React implementations via a
componentMap, so the same schema can target different component sets. - Isolated per-instance state — each mounted
Bonsaitree owns its ownvaltiostore, scoped via React context. Nothing about which data renders lives in shared or module-level state. - 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.
- Structural nodes —
eachandifhandle looping and conditional rendering directly in the schema, with two-way binding preserved inside loops. - Sandboxed expression language —
{{ }}bindings are evaluated by a small, fixed-surface formula language, never executable JavaScript. - 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.
Installation
npm install @pebble-software/bonsai
# or
pnpm add @pebble-software/bonsai
# or
yarn add @pebble-software/bonsaiPeer dependencies:
reactandreact-dom18 or 19- A bundler that understands ES modules (Vite, Next, Rspack, etc.)
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.
Working in this monorepo
pnpm install
pnpm --filter @pebble-software/bonsai run buildThe 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.
Quick start
import { Bonsai, type Config } from "@pebble-software/bonsai";
const template: Config = {
type: "card",
args: { className: "p-6 flex flex-col gap-4" },
children: [
{
type: "label",
args: { htmlFor: "name" },
children: ["Enter your full name"],
},
{
type: "input",
args: { id: "name", type: "text", value: "{{profile.name}}" },
},
{
type: "button",
args: { variant: "animated" },
children: ["Continue"],
},
],
};
export function Example() {
return (
<Bonsai
template={template}
captureUrl="/v1/capture/some-form-id"
expiry="2026-12-31T00:00:00Z"
data={{ profile: { name: "Ada Lovelace" } }}
/>
);
}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.
Package exports
| Export | Type | Description |
| --- | --- | --- |
| Bonsai | component | Universal entry point. Renders the SSR path on the server and the interactive client path in the browser. |
| 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. |
| componentMap | object | Maps schema component identifiers to React implementations. |
| controlledInputMap | object | Defines how specific component types behave as controlled inputs (value prop, change handler, value extractor). |
| Config | type | Runtime type describing a Bonsai node. |
| SignatureProps | type | Props contract for the built-in signature capture component. |
Bonsai accepts:
interface BonsaiProps {
template: Config;
captureUrl: string; // POST target for the submit handler
expiry: string;
data?: any; // initial store data
status?: "pending" | "captured" | "completed" | "error" | "revoked";
render?: "web" | "pdf" | "preview";
}render: "web"(default) mounts the tree inside a<form>whose submit handler POSTs the captured data tocaptureUrl."preview"renders the same form UI flagged withdata-mode="preview"for host styling."pdf"renders a plain<div>wrapper with no form — the layout used for print/PDF output.statusother than"pending"disables capture: the submit handler no-ops instead of POSTing.
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.
Configuration schema
The schema is defined in library/schemas/src/bonsai.schema.ts and exported as Config.
- Node shape: each node is either a string literal (rendered directly, and itself eligible for
{{ }}bindings) or an object with:type: a value fromCOMPONENT(see Component reference), including the structuraleachandiftypes (see Structural nodes).args: props for the resolved React component. Allargsvalues are eligible for dynamic bindings.children: an optional array of nestedConfignodes.
- Validation:
Configis a strict, discriminated union with a tailored prop contract per component (e.g. inputs definevalue,placeholder, etc.) — unknown props on a known component type are rejected, not silently dropped. Invalid configs fail fast during parsing. - Conditional rendering is a dedicated
ifnode, not a genericconditionprop on any component — see Structural nodes.
Example node:
{
"type": "checkbox",
"args": {
"id": "terms",
"checked": "{{agreements.terms}}",
"className": "mt-4"
},
"children": ["I agree to the terms"]
}Complete example templates live in the repo's examples/ directory, including a kitchen-sink template that exercises every component type.
Dynamic data binding
- 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. - Paths support dot notation (
user.email) and array indexing (addresses[0].street). - Missing keys resolve to
undefined; use thedefault(value, fallback)helper for a fallback inline, or handle defaults in host data. - Any
argsprop or string child can contain a binding. - Besides your own
data, the instance's top-level state fields —status,expiry, andsubmitting— are readable from bindings too (e.g. anifon{{submitting}}). - Inside an
eachrow, 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.orgNamefrom outside the loop) — see Structural nodes. - On the client, evaluating a node's bindings is also what subscribes that node to the store — see Controlled inputs & state.
Expression language
Everything inside {{ }} is a small, sandboxed formula language — never executable JavaScript. Template configs are typically authored by one party and rendered for another (including server-side, for PDF/email delivery), so the language surface is a closed, fixed set: no eval, no Function(), no way to reach a constructor, a prototype, or any value not explicitly reachable from your data.
Values & paths
{{ name }} identifier
{{ user.email }} dot path
{{ items[0].label }} bracket index (numeric or string)
{{ "literal string" }} string literal ('single' or "double" quotes)
{{ 42 }} {{ 3.14 }} number literal
{{ true }} {{ false }} {{ null }}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.
Operators
!a -a +a ~a unary
a + b a - b a * b a / b a % b a ** b arithmetic
a & b a | b a ^ b a << b a >> b a >>> b bitwise
a === b a !== b a == b a != b
a < b a > b a <= b a >= b comparison
a && b a || b a ?? b logical (short-circuit; returns
the operand, not a boolean — same
as JS: `0 && 5` is `0`, not `false`)
a ? b : c ternaryHelpers
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:
| Helper | Signature | Notes |
| --- | --- | --- |
| 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. |
| formatCurrency | formatCurrency(value, currency?, locale?) | Defaults "USD" / "en-US". |
| upper / lower / trim | (value) | String utilities; nullish-safe. |
| concat | (...values) | Joins with no separator, nullish-safe. |
| join | (array, separator?) | Default separator ", ". |
| default | (value, fallback) | Falls back on null/undefined/"" (not 0 or false). |
| length | (value) | String or array length; 0 otherwise. |
| count | (array) | Array length; 0 for non-arrays. |
| sum | (array) | Coerces entries to numbers, non-numeric entries count as 0. |
| min / max | (...values) | Accepts nested arrays or bare numbers. |
| round | (value, digits?) | Default 0 digits. |
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.
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.
Lambdas over arrays
A fixed set of array methods accept an inline arrow-style callback:
{{ applicants.filter(a => a.age >= 18).map(a => a.name) }}
{{ applicants.some(a => a.age < 18) }}
{{ applicants.every(a => a.verified) }}
{{ applicants.find(a => a.age > 65) }}
{{ applicants.map((a, i) => i) }} -- second param is the indexAllowlisted 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.
Structural nodes: each & if
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.
if — renders its children when condition is truthy, nothing otherwise. No wrapper element is introduced; multiple children render as siblings.
{
"type": "if",
"args": { "condition": "{{applicant.active}}" },
"children": [{ "type": "p", "args": {}, "children": ["{{applicant.name}}"] }]
}each — loops over an array binding, introducing an alias for each item:
{
"type": "each",
"args": { "items": "{{applicants}}", "as": "applicant", "index": "i", "key": "{{applicant.id}}" },
"children": [{ "type": "p", "args": {}, "children": ["{{i}}", ": ", "{{applicant.name}}"] }]
}items(required): a binding resolving to an array.as(required): the alias name each row's children use to refer to the current item (applicant.name, notapplicants[i].name).index(optional): an alias for the current loop index (a plain number, not a store path — an index has nothing to write back to).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.- 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 overapplicantseven thoughorgNameisn't a field onapplicant. - 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 asitemsis 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. eachnodes can nest; each level's alias stacks on top of the outer one.
Controlled inputs & state
- Each mounted
Bonsaiinstance gets its ownvaltiostore — 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. useUpdateData()(call it from inside the subtree) returns anupdateData(path, value)function that mutates the store'sdatausing the same path grammar as bindings.- Controlled components registered in
controlledInputMapautomatically wire up change handlers:- Value props point at a binding (e.g.
value: "{{profile.name}}"). - 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}}, anifcondition) update in the same event as the keystroke that changed them. - When something mutates the store's data, every node whose binding reads that path re-renders; unrelated nodes don't.
- Value props point at a binding (e.g.
- Non-controlled components simply receive evaluated props and behave as standard React elements.
- Submission is handled internally: the client render mounts its own
<form>and POSTs{ data: store.data }tocaptureUrlon submit. - Reactivity model: every leaf reads the store itself (via
valtio'suseSnapshot) 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.
Component reference
The full, authoritative list of component types is the COMPONENT enum in @bonsai/constants (library/constants/src/components.ts) — componentMap (src/lib/ui/registry.ts) has an entry for every value in it.
| Category | Schema types |
| --- | --- |
| Structural (control what renders, not a real element) | each, if |
| Plain elements | div, span, a, p, h, ol, ul, li, image |
| 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 |
| Tabular | table (+ table-header, table-body, table-footer, table-head, table-row, table-cell, table-caption) |
| 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) |
| Other | button, label |
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. Everything else just receives its evaluated args as props.
Need a component that isn't listed? Add it to
COMPONENT/COMPONENT_LISTin@bonsai/constants, add its prop contract to@bonsai/schemas, and register the React implementation incomponentMap(andcontrolledInputMapif it should behave as a controlled input) — see Extending Bonsai.
Styling
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.
- The design system lives in
src/index.css: an@themetoken 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. - Every rendered tree is wrapped in a
.bonsai-rootelement carrying adata-modeattribute (web/pdf/preview), so hosts can scope their own overrides per render mode. - 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
@sourceso 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. - 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-rootdescendants for finer overrides.
Extending Bonsai
- Add a component:
- Extend
COMPONENTandCOMPONENT_LISTin@bonsai/constants. - Add prop validation in
@bonsai/schemas. - Register the React implementation in
componentMap. - Optionally register controlled behaviour in
controlledInputMap.
- Extend
- Custom data handling:
- Use
useUpdateData()from a component mounted inside aBonsaiinstance's subtree for simple mutations.
- Use
- Alternate styling:
- Redefine the theme's CSS variables before mounting Bonsai — see Styling.
Development
Issues and pull requests are welcome at PebbleSoftwareInc/pebble-bonsai. This package lives at apps/bonsai in the monorepo.
pnpm lint # Static analysis using ESLint
pnpm test # Run the test suite (vitest)
pnpm type-check # TypeScript without emit
pnpm build # Bundles dist/ (ESM + type declarations) via Vitedist/ 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.
