npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 sizeXXSsizeLG 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