@miniapp-studio/builder
v0.0.1
Published
Craft.js visual editor for Miniapp Studio documents.
Readme
@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.
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.
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; 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 — 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:
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:
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.
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, 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:
<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 to work with, so a host
on another locale queries the translated name from the catalogue it supplied.
Further reading
documents/theme-tokens.md— the two custom-property namespaces and why they stay disjoint.documents/dom-contract.md— which DOM selectors are a promise to hosts and tests.documents/serialization-mapping.md— canonical tree to Craft state and back.
