# `@miniapp-studio/builder`

The Craft.js visual editor: canvas adapters, panels, and serialization back to
the canonical document. This is the only package allowed to import Craft,
`antd`, `lucide-react`, or `@dnd-kit/*`, and nothing a runtime route can
reach may import it — see
[Authoring configuration versus runtime configuration](../../documents/runtime-bundle-boundary.md).

Craft state stays below the public boundary, so hosts exchange canonical
contracts only. `StudioBuilder` is the provider/composition boundary; schema
normalization, field-model extraction, controls, property commits, canvas-node
adaptation, and output dialogs are focused modules behind it. `BuilderWorkspace`
owns the workspace row — two icon rails around a three-track splitter — and
which section each panel shows, and nothing else, panel width arithmetic and the
hex-colour rule are pure modules, the component icon mapping lives here rather
than in a definition, and the selection shortcuts are the only place a key press
reaches Craft's `delete`.

Each panel is kept off the machinery it drives. The Data panel holds no Craft
custom-data access, selection reading, or loop controls of its own, and the
Theme and Events panels hold no theme reference counting, token-capable field
recognition, or `custom.studio` access — all of that lives in core modules or in
dedicated builder modules beside them.

The builder has no UI kit of its own. `src/components` holds inert logic only —
the background CSS grammar, the hex rule, the common palette, and the popup-root
context — and every control is antd, composed at the panel that uses it. Three
panel modules exist purely to add what antd omits: `field` carries the grid
hooks `Form.Item` puts out of reach and turns a description into a tooltip on
the label, `color-popover` adds the dialog role,
Escape, and focus return `Popover` has no opinion about, and `color-sources`
lists its swatches as antd buttons rather than colour-picker presets, because a
theme swatch must carry a token _identity_, which a preset cannot.

## Property configuration

A component's properties are **declared**, not discovered. `StudioBuilder` takes
a `properties` registry alongside `registry`, and a component with no
registration is not editable in the Properties panel — it says so rather than
showing a panel guessed from its schema.

```ts
import { BuilderPropertiesRegistry } from "@miniapp-studio/builder";

const properties = new BuilderPropertiesRegistry();
properties.register(badgeDefinition, {
  groups: [
    {
      id: "content",
      label: "Content",
      fields: [
        { name: "label", control: { kind: "text" } },
        {
          name: "tone",
          label: "Tone",
          // Of the panel's four columns. Defaults to 2.
          span: 4,
          control: {
            kind: "segmented",
            options: [
              { value: "info", label: "Info" },
              { value: "success", label: "Success" },
            ],
          },
        },
      ],
    },
  ],
});
```

zapp's own configurations ship separately, in
[`@miniapp-studio/zapp-builder`](../zapp-builder/README.md); this package knows
no specific component.

### What a configuration declares, and what it must not

A configuration declares **presentation**: which properties appear, in what
order, under which section, and through which control. Every control kind is a
member of one closed set — `text`, `textarea`, `url`, `number`, `slider`,
`switch`, `select`, `segmented`, `color`, `background` — plus `custom`, which
supplies its own component for a genuinely one-off editor. A field may also
declare a `placeholder` and `clearable`, an inline control that empties it.

It also declares **layout**, but only as a column count. A panel section is four
columns wide, and a field's `span` says how many it takes — 1 to 4, defaulting
to 2. That is the whole vocabulary: no widths, no breakpoints, so a declaration
lays out the same whether the panel is dragged to its 240px minimum or its
480px maximum. The grid flows, so spans are read in order — four `padding*`
fields at `span: 1` share a row only when what precedes them ended on a row
boundary.

It declares no bounds. `min`, `max`, `step`, `minLength`, and `maxLength` are
read from the component's Zod schema, so a configuration and a schema cannot
drift apart. `slider` needs a bounded range to travel and falls back to a typed
box when the schema declares none.

The schema also stays the arbiter of what is _legal_. A `select` or `segmented`
option whose value the property's own schema rejects is a registration error,
which is what lets a closed set be declared over a property merely typed
`z.string()` — a font weight, a flex alignment — without the two disagreeing.

### Registration throws

`register()` resolves the configuration against the definition immediately and
throws, naming the component and the property, when it does not fit: a property
the schema does not describe, a property two groups claim, a duplicate or empty
group id, an empty group, a control the property's type cannot carry, an option
value the schema rejects, or a second registration for the same component. A
configuration is developer code, like `defineComponent` — not host input — so a
mistake surfaces at startup rather than on an author's first click.

Resolution happens once, so rendering a panel is one `Map` lookup.

### Templates and bindings

A `text`, `textarea`, or `url` control on a property the definition derived as
templatable edits an inline `{{data.path}}` template directly, with no mode row.
Every other property gets its declared control plus a fixed/bound toggle, and
the two are never on screen at once. A property is a template or a binding,
never both.

### One vocabulary, two panels

Properties and styles are declared in the same types —
[`field-config.ts`](src/properties/field-config.ts) — and rendered by the same
component, `ConfiguredSections`. A style entry names its control exactly as a
property does; the swatch and the gradient editor used to be picked from the
entry's _name_. What differs is only what stands behind a commit: a property is
checked against its Zod schema, so it carries constraints, a required flag, and
a default, while a style entry has none of those and is never templatable or
bindable.

Event-handler and data-source **parameters** stay discovered: typed JSON from a
Draft 7 `paramsJsonSchema` rather than authored properties, so they keep
`discoverFields`, `SchemaField`, and with them `tokenGroupFor` — the name
heuristic, which now survives for discovered parameters alone.

## Validation

Validate a fixture containing the component's defaults before shipping it:

```ts
import {
  ROOT_NODE_ID,
  validateDocument,
  validateTreeProps,
  type NodeTree,
} from "@miniapp-studio/core";

const fixture = {
  [ROOT_NODE_ID]: {
    id: ROOT_NODE_ID,
    type: "Badge",
    props: badgeDefaults,
    style: {},
    children: [],
  },
} satisfies NodeTree;

const documentResult = validateDocument(fixture);
if (!documentResult.valid) throw new Error("invalid canonical fixture");

const propsResult = validateTreeProps(documentResult.tree, registry);
if (!propsResult.valid)
  throw new Error("props do not match the component schema");
```

`defineComponent` rejects invalid defaults immediately. The builder uses the
same validation layers for change, save, and publish
output. Invalid output is blocked and reported through `onValidationError`.
The renderer validates again at its runtime boundary and never partially
renders a structurally invalid document.

## Colours and backgrounds are picked, not typed

A colour field is **one swatch button** opening one popup, in the Properties
panel, the Styles panel, and beside a theme colour token. Neither the row nor
the popup has a free-form colour text field. The popup offers three sources —
**Theme** (writing a canonical token reference), **Common** (a documented
palette constant), and **Custom** (the platform picker plus the host-owned
custom colours) — with clear and "add to custom colors". Custom colours are
host-owned exactly as templates are, through `customColors` /
`defaultCustomColors` / `onCustomColorsChange`; the builder writes no browser
storage.

The control is stateless with respect to its value, so a value the picker
cannot represent — `rgba()`, a named colour, a `{{data.path}}` template, a
theme reference — survives opening and dismissing the popup byte-for-byte and
changes only when the author explicitly picks or clears. Such a value renders
an explicit indeterminate swatch rather than a misleading black.

A **background** is a different control: a kind switch over colour, gradient,
and image. The persisted form is still one inert CSS string, or a token
reference for the plain themed colour, so no document schema changed. A pure
module parses and serialises the three shapes it authors and drops to a
verbatim `raw` mode for anything else, so opening the editor never destroys a
value it does not understand. A gradient stop may be a theme colour, written as
`var(--studio-color-<id>)`.

An image background can never be a theme token: a token value still rejects
`url(`, `;`, `{`, `}`, `expression(`, and `@import`. This is a UI for a
capability a node style already had, not a new one, and hosts remain
responsible for CSP.

## Save reusable node templates

Select any document node and choose **Save as template** in Canvas controls.
The selected node, all descendants, and named-slot content become one detached
snapshot. With the default palette it appears under **Elements → Custom**. A
host-defined palette supplies it directly in a `BuilderTemplateGroup`. Dragging
that entry creates fresh node ids, so placed copies never change when the
template metadata is edited or the template is deleted.
Templates contain nodes only: the destination document supplies its theme,
component registry, data sources, and event handlers.

Template persistence is host-owned. Control the collection when it should
survive builder remounts or be stored remotely:

```tsx
import { useState } from "react";
import { StudioBuilder, type BuilderTemplate } from "@miniapp-studio/builder";

function AuthoringPage({ document, registry }) {
  const [templates, setTemplates] = useState<readonly BuilderTemplate[]>([]);

  return (
    <StudioBuilder
      document={document}
      registry={registry}
      templates={templates}
      onTemplatesChange={setTemplates}
    />
  );
}
```

Use `defaultTemplates` for an uncontrolled in-memory collection. The builder
writes no browser storage. A template is a named canonical `NodeTree` with an
`editable` flag and optional `category` and `preview_img`. The palette puts the
image in a square card above its one-line name and actions. Builder-created
templates store the author's trimmed category; older ones need no category or
preview. The tree's `ROOT` is the saved selection root, and template metadata
never enters saved or published documents.

The Zapp builder demo seeds this collection with 32 home, layout, commerce, and
account patterns. Data-shaped UI such as product cards, vouchers, cart rows,
billing summaries, and membership cards expands into Container, Image, Text,
Link, Button, and input nodes before it reaches the document. The Demo places
registered elements and templates together in host-defined groups. Registered
elements use the same contract as presets: a non-editable template containing
one component. Authored templates can be inserted as editable node
groups and remain session-editable across demo page switches. This keeps the
component registry focused on reusable behavior rather than one persisted type
for every page arrangement.

Layout spacing is edge-specific. Container, LoopContainer, Header, and Paper
expose `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft` and the four
matching margin properties, so authors never have to trade one edge against a
single shorthand value. Header defaults use 8px vertically and 16px
horizontally.

## The builder token scale

The builder paints two things on one page: its own interface and the document
being edited. Only the document uses CSS custom properties.

The builder has no design vocabulary of its own. It adopts **antd's tokens** and
customises their values, in one `ThemeConfig` applied at the single
`ConfigProvider`. The builder writes almost no style of its own: an antd
component and its props say it, and what antd has no prop for is simply not
said. The package ships no stylesheet, authors no `*.module.css`, and a host
needs no CSS import.

A handful of `createStyles` blocks survive on a closed list, each in a
`*.styles.ts` module beside its component and reading that same token object:
the viewport resize handles, the node resize handle, and the canvas selection
and drop-target outlines. "Beside its component" is the contract, not a filing
habit — a `*.styles.ts` may only style elements the component next to it
renders. When a rule would exist only to re-dress an antd component, the
component's own props and the `Form`, `Button`, and `Segmented` tokens in
`editor/shell/antd-theme.ts` are the place to say it; when antd has no
equivalent — there is no `fieldset` — the answer is an antd component asked to
render that element, as `PanelSection` asks `Flex` for a `fieldset`. The rule
and its bounded exceptions are in
[`documents/styling-convention.md`](../../documents/styling-convention.md).

antd's `sizeXXS`…`sizeLG` already default to 4/8/12/16/24, so the 4 px grid is
adopted rather than restated. The rest of the scale is Inter body/control text
on `fontFamily`, JetBrains Mono labels on `fontFamilyCode`, 4 px radii, 24 px
controls from the derived `controlHeightSM`, low-contrast borders, and `#0d99ff`
on `colorPrimary`. Two measurements antd names nothing for — the layer glyph
size and one indent level, both belonging to the layers tree — live beside the
theme as `BUILDER_CUSTOM_TOKEN`.

The two namespaces cannot meet, structurally rather than by defence: the
document's `--studio-color-*` / `--studio-font-*` are real custom properties
minted onto the canvas scope element, while the builder's own values are JS
values baked into an emotion class. A document that names a token `surface` cannot repaint
the editor, and a node style of `var(--studio-ui-accent)` resolves to nothing
because no such property exists anywhere — so a document renders the same in the
builder and in the standalone renderer. Editor UI the builder draws _over_ the
document is no longer an exception to anything; it reads the same tokens.

### Resize a node on the canvas

A selected node draws one resize handle, on the axis its **parent** lays out
along: a child of a `direction: "row"` parent resizes horizontally, a child of
a `"column"` parent vertically, and a child of a block parent such as
`zapp.Paper` horizontally, because a block parent's child owns the inline axis
and nothing else. The gesture writes an ordinary canonical `StudioNode.style`
entry — `width` or `height`, plus `flexShrink: 0` when the parent lays out
along that axis, so the canvas cannot show a size the document does not
produce. Both are visible and clearable in the Styles panel, and a resized node
renders identically in `/builder` and `/renderer`.

One drag is one undo step, `Escape` cancels and restores, and the handle is
keyboard-operable (arrows step 1 percentage point, `Shift`+arrow 10 percentage
points). Width or height is stored as a percentage of the parent layout box,
so a resized node remains responsive when the viewport changes. Each selected
node owns and portals its controls to the canvas surface; there is no global
selection overlay or node-element registry. Which nodes resize is authoring
data: `ComponentDefinition.resizable` defaults to `true`, so a host component
is resizable without opting in. The starter set opts out only where a prop
already owns the size — `Text`, `Icon`, `zapp.Switch` — and for the four
full-surface components `zapp.PageLayout`, `zapp.Modal`, `zapp.Drawer`, and
`zapp.Popup`. ROOT is never resizable.

### Rearrange from the layers tree

The tree is the primary way to restructure a document: drag a row before,
after, or into another, or move it from the keyboard with `Ctrl`/`Cmd` and the
four arrows. Legality is Craft's own answer, never a second implementation of
it, so `LoopContainer`'s one-template rule and `Slider`'s hybrid rule hold for
free. A slot row is never a drag source but is a valid drop target.

### Property sections

The builder renders one collapsible section per group of a component's
[registered configuration](#property-configuration), in declared order. There is
no derived fallback and no trailing group: a property no group names is not
editable, which is what makes a deliberately hidden property expressible.
`collapsed` is an initial state only; section state is in memory, keyed by
`${componentType}:${groupId}`, and is never written to browser storage.

The style panel keeps its own fixed grouping over the CSS catalogue. Its labels
come from the builder's message catalogue rather than a host, which is why a
label may be a string _or_ a message id. Both panels share one collapse state,
so the style panel scopes its keys by `${componentType}:style` and a component
group and a style section with the same id cannot collide.

### Builder localization

The builder's own interface is fully localized, including the names a screen
reader
announces. English is the default and Vietnamese ships with it:

```tsx
<StudioBuilder
  document={document}
  registry={registry}
  locale="vi"
  messages={{ "action.publish": "Ship it" }}
/>
```

The catalogue is a flat `id → string` record per locale with `{{name}}`
interpolation, served by `react-i18next`: each builder mount creates one
isolated `i18next` instance with inline resources, synchronous init, and no
language detector or backend. Resolution layers English, then the locale, then
the host override, so a partial catalogue or a partial override degrades to
readable text rather than to `undefined`; an id the catalogue does not
declare is dropped. The builder holds no locale state and writes no storage:
the host owns the choice, exactly as it owns `url` and the panel layout.

antd ships the builder's controls, so its own locale bundle is switched with
the catalogue: `en` and `vi` both localize the strings antd owns, and a control
whose antd default would announce an English name of its own has that default
cleared in favour of the field's translated label.

Not localized, deliberately: component `label` and `category` come from the
registry, so a host needing them translated supplies translated definitions,
and core's validation messages stay English developer diagnostics. Accessible
names are stable **for the default `en` locale**; a locale-independent query has
only the canvas and viewport state attributes in
[`documents/dom-contract.md`](documents/dom-contract.md) to work with, so a host
on another locale queries the translated name from the catalogue it supplied.

## Further reading

- [`documents/theme-tokens.md`](documents/theme-tokens.md) — the two
  custom-property namespaces and why they stay disjoint.
- [`documents/dom-contract.md`](documents/dom-contract.md) — which DOM
  selectors are a promise to hosts and tests.
- [`documents/serialization-mapping.md`](documents/serialization-mapping.md) — canonical
  tree to Craft state and back.
