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 warnsLJS-305on anything else; component props are case-sensitive, soonChangewould be a different, ignored prop. - Never name a bound state
value, becausevalue.valueis unreadable. Name it for what it holds:checked,picked,open,current. bind()andcheckedsemantics:bindis the live two-way state and wins when present;checked(or the equivalent) is the initial state when unbound; avalueprop, 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 firesonchange; 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
addEventListenernever appears in block code. External listeners go throughlisten(); intervals and other resources clean up throughonMount’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,.valuefor reactive ones. Inside api methods and event handlers, read states withpeek(), as the dropdown’sgetValue/getDatado, so an imperative read never creates a phantom dependency..valuebelongs 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 + 10is frozen forever. Usecomputed(). The Modal shipped this exact bug and its fix repaired every composer (computed()). - Guard
focusoutwithisDisposing(). Removing a focused element firesfocusoutexactly 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.tsand 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.