Building blocks

Every block in the Studio catalog was built from one recipe, and the recipe is the point: it is precise enough that most of the catalog was built by AI agents running it in parallel. The recipe is the prompt. This chapter is that recipe, written so you (human or agent) can add the 41st block.

// the whole shape of a block, in one declaration
export const Switch = component('switch', {
    bind: Boolean,               // two-way state
    checked: false,              // initial state when unbound
    label: '',                   // label displayed beside the switch
    disabled: false,             // blocks interaction (native)
    onchange: Function,          // fires on user-initiated changes
    api: { toggle: Function },   // imperative surface via ref
}, (props, { bind }) => { /* ... */ });

Components as products

A component that works in an app and a component that is a product are different artifacts. The product needs an interface others can discover without reading source, proof that the implementation honors it, documentation that cannot drift, and a clean death. None of that happens by intention alone, and without a fixed recipe, every author (and every agent, every session) invents a slightly different shape: different class naming, different event casing, different cleanup discipline. Multiple authors on one catalog produce multiple dialects, and the catalog stops being a catalog.

The hardest item to get right is cleanup. Gesture systems (drags, resizes, outside-click closers) are where components accumulate document listeners they never remove. LemonadeJS v5’s plugins leaked exactly this way; the v6 recipe exists so the 40 current blocks provably do not.

The recipe

The recipe has five parts: a file shape, a contract-first workflow, a dialect, a gate, and the lessons the first 40 blocks paid for.

1. The file shape

components/<name>/
    package.json        @lemonadejs/<name>; exports ".", "./style.css",
                        "./contract.json"; peerDependency: lemonadejs
    src/index.ts        ONE file: header JSDoc + contract + component
    src/style.css       plain CSS, lm-<name>-* classes only
    <name>.test.ts      behavior tests; verify() is test #1
    demo.ts, demo.html  playground page (npm run dev auto-discovers it)
    --- generated, never hand-written ---
    contract.json       the interface     (npm run registry)
    verify.json         the proof         (npm run registry)
    dist/index.d.ts     typed projection  (npm run registry)
    README.md           the docs          (npm run docs)

Two parts of src/index.ts are load-bearing beyond the code itself: the header JSDoc becomes the README’s Overview, and the inline comment after each contract prop becomes that prop’s description in the generated props table. You write documentation exactly once, in the place it cannot drift from.

2. Contract first, verify() as test #1

Declare the interface before writing behavior. The contract literal is small enough to design in full. Then make conformance the first test in the file:

import { render as t, verify } from 'lemonadejs/test';

describe('components/switch', () => {
    it('passes verify(), the registry gate', () => {
        expect(verify(Switch).pass).toBe(true);
    });
    // behavior tests follow: every claim in the header JSDoc gets one
});

verify() mounts the component with defaults, exercises every declared prop as a plain value and as a live state, fires every event path, checks bind semantics and that the declared api is actually exposed, and fails on any engine warning during any check (Tests). While the contract is still a stub, verify() fails; when it passes, the interface is real. It is a to-do list that ends in a proof.

3. The conventions: one dialect

These are not style preferences; they are what makes 43 blocks by many authors read as one library.

  • CSS classes are strictly lm-<name>-* (lm-switch, lm-switch-track). No other prefix, no styling engine.
  • Visual variants are data-* attributes on the root (data-color="red", data-position="right"): selectable in CSS, inspectable in the DOM.
  • Events are lowercase: onchange, onopen, onitemclick. The engine warns LJS-305 on anything else; component props are case-sensitive, so onChange would be a different, ignored prop.
  • Never name a bound state value, because value.value is unreadable. Name it for what it holds: checked, picked, open, current.
  • bind() and checked semantics: bind is the live two-way state and wins when present; checked (or the equivalent) is the initial state when unbound; a value prop, where it exists, is the string the form submits. DOM semantics, three distinct jobs (see Two-way binding). current.set(v) commits a user-initiated change and fires onchange; parent writes stay silent.
  • A native element core where one exists. The Switch is a real <input type="checkbox">: form participation, disabled semantics and keyboard accessibility come from the platform, not from re-implementation.
  • Destroy-clean gestures go through listen(), armed mid-event. The Modal’s drag/resize tracker is the canonical shape: a single release function, replaced per gesture, every armed listener disposal-bound so a mid-drag unmount still cleans up:
let release = null;
const track = (move, done) => {
    release?.();                                  // never stack gestures
    const offs = [
        listen(document, 'mousemove', move),
        listen(document, 'mouseup', () => release?.()),
    ];
    release = () => {
        offs.forEach((off) => off());             // off(): fire-once, self-pruning
        release = null;
        done?.();
    };
};
  • Raw addEventListener never appears in block code. External listeners go through listen(); intervals and other resources clean up through onMount’s return value. See Lifecycle and Destroy. The suites assert listener balance, not good intentions.

4. The gate

npm run registry    contract.json + verify.json + dist/index.d.ts per
                    block, registry.json index. Any verify() failure
                    exits 1. No contract, no entry; no proof, no entry.
npm run docs        regenerates every README + the catalog index
npm run dev         the playground; every block's demo on :3000

The .d.ts is generated as a projection of the contract, never written by hand: TypeScript users get editor types, agents get JSON, one source of truth (Contracts).

5. The lessons (paid for once, encoded here)

  • peek() for imperative reads, .value for reactive ones. Inside api methods and event handlers, read states with peek(), as the dropdown’s getValue/getData do, so an imperative read never creates a phantom dependency. .value belongs in templates and computed() derivations, where subscribing is the point.
  • Never derive from a prop at setup. Setup runs once; const top = props.top.value + 10 is frozen forever. Use computed(). The Modal shipped this exact bug and its fix repaired every composer (computed()).
  • Guard focusout with isDisposing(). Removing a focused element fires focusout exactly like a user clicking away; three blocks independently hit it. Anything that closes or commits on blur checks the engine first (Destroy).
  • Behavioral parity means reading the source, not the interface. The first Modal port matched the v5 .d.ts and missed the 768-line implementation’s actual behavior; it had to be rebuilt to behavioral fidelity. A type signature is not a specification.
  • jsdom cannot do layout. Anchoring, overflow flipping and drag geometry need a real browser; the repo escalates to headless-Chrome probes (npm run probe) for exactly those assertions. Know which of your claims jsdom can actually check.

The agent workflow

This recipe was executed mostly by parallel AI agents: waves of six to twelve at a time, each assigned one block in its own folder. The constraints that made that work are part of the recipe:

  • Disjoint folders, no shared-file writes. Each agent owns components/<name>/ and touches nothing else; generated shared files (registry.json, the catalog README) are produced by the scripts, never edited by hand.
  • The source is the spec. Agents porting v5 plugins read the v5 implementation, not its type declarations (lesson above).
  • The gate is the reviewer. verify() plus the behavior tests are the done-signal; a block that fails the registry gate is not done, whatever its author believes. This is the loop the whole framework optimizes; see Agents.

If you are an agent reading this: this chapter plus llms.txt is the complete prompt for building a conforming block.

Compared to scaffolds, workbenches and library contribution

  • Copy-source scaffolds give you the file shape, and full ownership of edits afterwards. The recipe’s shape additionally comes with a gate: edits can be re-proven with verify().
  • Workbench-driven development (Storybook and its peers) produces demos and human visual review, a different kind of evidence, and a valuable one. verify() covers the machine-checkable half; the playground demo keeps the human half.
  • Contributing to an established library means adopting its styling system, review queue and release train: the coordination that keeps a large shared codebase coherent. The Studio recipe is designed so a conforming block can be produced, and proven, in one session, because the rules of the commons are enforced by scripts rather than by review.

The honest limit: a gate proves conformance to the contract you wrote, not that the contract is the right one. Taste, meaning which props a block should have, is still the author’s job, which is why the recipe says to mine the best existing libraries for their prop vocabularies before declaring a contract.

In practice

The smallest real block, end to end. This is @lemonadejs/switch, condensed:

/**
 * <Switch /> is built on a real <input type="checkbox">: native form
 * participation, native disabled semantics, native keyboard a11y.
 */
import { component, html } from 'lemonadejs';

export const Switch = component('switch', {
    bind: Boolean,               // two-way state
    checked: false,              // initial state when unbound
    label: '',                   // label displayed beside the switch
    disabled: false,             // blocks interaction (native)
    onchange: Function,          // fires on user-initiated changes
    api: { toggle: Function },   // imperative surface via ref
}, (props, { bind }) => {
    const current = bind(props, props.checked.value);
    const toggle = () => {
        if (!props.disabled.value) current.set(!current.value);
    };
    props.ref?.({ toggle });

    return html`<label
        class="lm-switch ${() => (current.value ? 'lm-switch-on' : 'lm-switch-off')}">
        <input type="checkbox" class="lm-switch-input"
            checked="${current}" disabled="${props.disabled}"
            onchange="${(e) => current.set(e.target.checked)}" />
        <span class="lm-switch-track"><span class="lm-switch-thumb"></span></span>
        ${() => props.label.value && html`<span class="lm-switch-label">${props.label}</span>`}
    </label>`;
});

export default Switch;

Then: verify(Switch) as test #1, behavior tests for every claim in the header, a demo page, npm run registry, and the block exists with its contract, proof, types and README, deployable three ways (Deployments).

Reference

The checklist. A block ships when every line is true:

[ ] components/<name>/ matches the file shape exactly
[ ] contract declared first; prop names lowercase; inline comments on
    every prop (they are the docs)
[ ] verify(Block).pass === true, as test #1
[ ] behavior tests for every claim in the header JSDoc
[ ] classes lm-<name>-* only; variants as data-* attributes
[ ] events lowercase; no bound state named "value"
[ ] bind = live two-way (wins) / checked = initial / value = form submit
[ ] gestures: one persistent cleanup; onMount returns its cleanup;
    focusout guarded by isDisposing()
[ ] demo.ts renders every prop family; layout claims probed in Chrome
[ ] npm run registry passes: contract.json, verify.json, d.ts generated
[ ] npm run docs run: README regenerated, catalog index updated

The engine chapters behind the recipe: State, computed(), Two-way binding, Lifecycle, Destroy, Contracts, Errors.