Contracts

A plain function is a component. component(name, contract, fn) is what you do when a component becomes a product: a block, a library entry, anything other frameworks or other agents consume. The contract declares the interface once, as a runtime artifact, and everything derives from it. This is the real Switch block, abridged:

import { component, html } from 'lemonadejs';

export const Switch = component('switch', {
    bind: Boolean,                // bindable value, type inferred
    checked: false,               // prop: boolean, default false
    label: '',                    // prop: string, default ''
    size: '',                     // attribute "small" arrives coerced, typed
    disabled: false,
    onchange: Function,           // event out
    api: { toggle: Function },    // imperative surface via props.ref
}, (props, { bind }) => {
    const current = bind(props, props.checked.value);
    const toggle = () => {
        if (!props.disabled.value) current.set(!current.value);
    };
    props.ref?.({ toggle });      // fulfill the declared api
    return html`<label class="lm-switch ${() => current.value ? 'lm-switch-on' : 'lm-switch-off'}">
        ...
    </label>`;
});

One literal, four kinds of entry: literal values declare a prop with its type and default (label: '' → string, default ''), constructors declare type only (data: Array), on* keys declare events out, api: declares the imperative surface delivered through props.ref. The optional bind entry declares the two-way value, so its type and default are inferred the same way, and a contract default feeds the bind() tool when the caller leaves the component unbound.

What derives from the contract

Declared props arrive as live states, in every deployment. This is the key rule of the contract layer. Whether the component is mounted by a lemonade parent, driven by a custom element attribute, or wrapped by the React adapter, props.label is a State<string>: ${props.label} in templates, props.label.value in logic. One component model everywhere. The wrapper normalizes whatever the caller sent: a live state is shared as-is (the caller keeps write access), a plain value is boxed, a missing prop becomes a state holding the default.

And they are non-optional, precisely typed. Because the engine constructs every declared prop, the types say so: props.max is State<number>, not State<number> | undefined, so props.max.value - props.min.value typechecks with no ! and no as casts anywhere in the component body. Declared on* events are invocable as-is (props.onchange?.(value)), and the api type a ref receives is named by ApiOf<typeof Switch>: never hand-roll an api shape the contract already defines.

Attribute strings are coerced to the declared type. "5" becomes 5 for a number prop; booleans follow HTML semantics, so present (even empty) is true, "false" and "0" opt out; a removed attribute returns a boolean to false and anything else to its default. Declared coercion is honoring a contract, not guessing. Passing a genuinely wrong type warns LJS-401 in dev, and contract prop names must be lowercase, because they become HTML attributes (LJS-401 warns), as must event names (LJS-305). A prop the contract does not declare warns LJS-402 with a did-you-mean suggestion (edit distance against the declared names against the declared names, so lable suggests label); ref, children, expose and declared on* events are always accepted and never warn.

Two contract subtleties worth declaring deliberately. null means any: use it for union-typed values a constructor cannot express, so bind: null for a string | number id leaves the bindable value unchecked. And declaring your own onchange alongside bind keeps your signature: onchange(id, node) is fine; bind’s default (value, oldValue) shape applies only when you do not declare one.

contract(C) returns the schema at runtime. Plain JSON (name, props with type and default, bind, events, api): the interface an agent reads instead of the source, in one request:

import { contract } from 'lemonadejs';
contract(Switch)
// { name: 'switch',
//   props: { checked: { type: 'boolean', default: false },
//            label:   { type: 'string',  default: '' }, ... },
//   bind: { type: 'boolean' }, events: ['onchange'], api: ['toggle'] }

The .d.ts is a projection, never a source. The Studio build generates each block’s type declarations from the contract, and the file opens with GENERATED from contract.json, do not edit. TypeScript humans get editor types, agents get JSON, and they cannot disagree because both are projections of the same literal.

verify(C) is the conformance proof. From lemonadejs/test, it exercises the contract mechanically: mounts with defaults, mounts with every prop as a plain value and as a live state (then touches it), passes every declared event a callback, drives bind with external writes, and asserts every declared api method is actually exposed through props.ref. The strict part: any LJS-* engine warning during any check fails that check. Conformance includes silence: a component that mounts but warns does not pass.

import { verify } from 'lemonadejs/test';
verify(Switch)   // { component: 'switch', pass: true, checks: [...] }

In practice

The Studio registry is the receipt. npm run registry in the library repo extracts every block’s contract, runs verify() against it in jsdom, generates the .d.ts projection, and writes four artifacts per block: contract.json, verify.json, dist/index.d.ts, and an entry in components/registry.json, the search index an agent reads in one request. The script is a gate: any block whose proof fails breaks the build (process.exit(1)). No contract, no entry; no passing proof, no entry.

Current state of the registry: 43 blocks, all verified, 786 checks. The Switch above passes 22: one mount, nine props twice each (plain and live), the event, the bind, the api. The datagrid’s contract declares 8 props, 5 events and a 6-method api (getSelected, setSearch, sort, page, refresh, setColumn), and an agent choosing whether to use it reads that from contract.json without ever opening the 600-line source.

Publishing is the only step that changes. App-internal components stay plain functions: the four template rules, nothing else. The contract is one extra wrapper for a different act: shipping. And once published, the same literal drives every deployment: createWebComponent(Switch) derives a full custom element, adaptReact(Switch) a first-class React component, and <${Switch} expose /> publishes the declared api as an app-wide service (see Sugar).

Reference

import { component, contract } from 'lemonadejs';

const C = component(name, contractLiteral, fn);
// contractLiteral entries:
//   prop: literalValue        type + default inferred (string/number/boolean/array/object)
//   prop: Constructor         type only (String, Number, Boolean, Array, Object, Function)
//   prop: null                ANY type (unions: bind: null for string|number ids)
//   onsomething: Function     declared event (lowercase, LJS-305); declaring
//                             onchange beside bind keeps YOUR signature
//   bind: value|Constructor   bindable value; default feeds bind() when unbound
//   api: { method: Function } imperative surface via props.ref, ApiOf<typeof C>
//                             names the type a ref receives

contract(C)        // → { name, props: { k: { type, default? } }, bind, events, api } | null

import { verify } from 'lemonadejs/test';
verify(C)          // → { component, pass, checks: [{ name, pass, detail? }] }
                   //   any LJS-* dev warning during a check fails it

Coercion (attribute strings → declared type): "5"5 for numbers; booleans use presence semantics ("" is true, "false"/"0" are false); removed attribute → false for booleans, the default otherwise. Inside the component, declared props are always states, non-optional (ContractProps<C>; no !, no casts); callers may pass states, plain values or attribute strings (ContractInput<C>). Wrong types and non-lowercase names warn LJS-401; undeclared props warn LJS-402 with a did-you-mean hint; see Errors for the full code table.