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.