Worked Examples
Four surfaces written end to end, a banner, a form, a list, and a strip of quick actions: a shared vocabulary, four documents, and the behavior each must produce. The banner and the form live as executable vectors in conformance/, in the format of the conformance suite spec, and every mechanic the other two use is pinned by vectors of its own. Together they exercise the contract: context, expressions, state, events, built-in and custom actions, consent gating, async completion with a typed result and a typed failure payload, keyed lists edited in place, reactions to state, lifecycle signals, and functions the host computes. They illustrate the mechanics, not the boundary of what the mechanics serve.
Recommended integration architecture
Non-normative, but strongly recommended and followed by the sample apps: keep the design system pure, and give Milano exactly one doorway into it.
- Design system (
DesignSystem/,designsystem/): plain UI components taking typed models (a banner model with layout, heights, and alignment; a button model with a label, an enabled flag, and a tap closure). Zero Milano imports: every component is previewable and unit-testable on its own, and reusable outside Milano entirely. - Milano bridge (
MilanoBridge/,milanobridge/): the only UI-layer code that imports Milano. For each component, an initializer that builds the model from a MilanoNode (properties read once, events wired into the model’s closures), and the renderer that registers it. One obvious file per component; the whole Milano surface of the design system is auditable at a glance. - Environment: engine setup, builders, the action funnel, the observer. Screens depend on this service, never on engine internals.
The result: Milano never leaks into the components, the components never learn about documents, and replacing either side leaves the other untouched.
The vocabulary
A minimal consumer vocabulary, examples, with twelve component types, four custom actions, and the functions the app computes:
| Component | Properties | Events | Children |
|---|---|---|---|
Banner | backgroundImageUrl: string | yes | |
Column | yes | ||
Text | text: string, visible: bool? | ||
Button | label: string, enabled: bool | tap (no payload) | |
TextField | label: string, value: string | change: string | |
NumberField | label: string, value: double | change: double | |
Checkbox | label: string, checked: bool | change: bool | |
Badge | label: string, tone: enum(info, warning, danger) | select: enum(info, warning, danger) | |
Meta | key: string; strict, so an undeclared property is rejected at the gate | ||
Row | spacing: int? | yes | |
Card | cornerRadius: int?, accessibilityLabel: string? | tap (no payload) | yes |
Icon | name: enum(person, list, search, edit, settings, help) |
| Action | Declared | Parameters |
|---|---|---|
openUrl | Vocabulary (global) | url: string |
submitContact | Vocabulary (global) | name: string, surname: string, email: string, phone: string?; fails with enum(invalid, unavailable) |
navigate | Vocabulary (global) | screen: enum(profile, catalog, settings, help) |
logEvent | Vocabulary (global) | event: string, surface: string, position: int |
| Function | Arguments | Returns |
|---|---|---|
formatMoney | int, string | string |
shout | string | string |
parseInt | string | int? |
scale | double, int | double |
tone | enum(info, warning, danger) | enum(info, warning, danger) |
round | double, int | string |
Functions are declared like actions and computed by the app, not by Milano (vocabulary schema spec, Function declarations). round is declared deliberately: the contract’s own $round is a different function in a different namespace, and neither shadows the other.
What these components look like is entirely the consumer’s business: Milano only guarantees their properties arrive resolved and typed, and their declared events dispatch.
The artifact lives once at conformance/examples/vocabulary.json, beside its vectors per the suite layout: nothing references it by path, mirroring the runtime, where a document never names its vocabulary. It validates against the official meta-schema schemas/vocabulary.schema.json.
The banner
A background image, a personalized title, a subtitle, and a call-to-action button that asks the host to open a URL.
{
"version": "2.0.0",
"context": { "userName": "string" },
"root": {
"type": "Banner",
"id": "banner",
"properties": { "backgroundImageUrl": "https://cdn.example.com/promo.jpg" },
"children": [
{
"type": "Text",
"id": "title",
"properties": { "text": { "$expr": "$concat('Hello, ', context.userName)" } }
},
{
"type": "Text",
"id": "subtitle",
"properties": { "text": "A summer of savings starts today." }
},
{
"type": "Button",
"id": "cta",
"properties": { "label": "Learn more", "enabled": true },
"on": {
"tap": [ { "action": "openUrl", "url": "https://example.com/promo" } ]
}
}
]
}
}
What it demonstrates:
- Structure without data: the document declares that it reads
context.userName; the host injects the value ("Ada"), and the title resolves toHello, Ada. The same cached document greets every user. - The action boundary: tapping the button dispatches
openUrlwith its capturedurlparameter to the host’s action handler. Milano does not open URLs; the host does. - Vector:
conformance/examples/banner-open-url.jsonexpects the resolved tree, the dispatched action, and zero occurrences.
The shopping list
A list the document owns and edits in place: one $repeat keyed on each item, the three array actions behind its buttons, a watch that clears the draft whenever the list changes, a lifecycle binding, and a price formatted by a function the app computes.
{
"version": "2.1.0",
"vocabulary": { "name": "examples", "min": "1.0.0" },
"state": {
"items": { "array": { "record": { "id": "string", "name": "string", "cents": "int", "done": "bool" } } },
"draft": "string",
"seen": "bool"
},
"root": {
"type": "Column",
"id": "list",
"children": [
{
"type": "Text",
"id": "heading",
"properties": { "text": { "$expr": "$concat('Basket: ', $str($length(state.items)), ' items')" } }
},
{
"type": "TextField",
"id": "draft",
"properties": { "label": "Add an item", "value": { "$expr": "state.draft" } },
"on": { "change": [ { "action": "$set", "key": "draft", "value": { "$expr": "event" } } ] }
},
{
"type": "Button",
"id": "add",
"properties": { "label": "Add", "enabled": { "$expr": "!$isEmpty($trim(state.draft))" } },
"on": {
"tap": [
{
"action": "$append",
"key": "items",
"value": { "id": "", "name": "", "cents": 250, "done": false }
},
{
"action": "$update",
"key": "items",
"at": { "$expr": "$length(state.items) - 1" },
"field": "id",
"value": { "$expr": "$trim(state.draft)" }
},
{
"action": "$update",
"key": "items",
"at": { "$expr": "$length(state.items) - 1" },
"field": "name",
"value": { "$expr": "$trim(state.draft)" }
}
]
}
},
{
"type": "$repeat",
"id": "rows",
"items": { "$expr": "state.items" },
"as": "item",
"key": { "$expr": "item.id" },
"children": [
{
"type": "Checkbox",
"id": "done",
"properties": {
"label": { "$expr": "$concat(item.name, ': ', formatMoney(item.cents, 'EUR'))" },
"checked": { "$expr": "item.done" }
},
"on": {
"change": [
{
"action": "$update",
"key": "items",
"at": { "$expr": "item_index" },
"field": "done",
"value": { "$expr": "event" }
}
]
}
},
{
"type": "Button",
"id": "remove",
"properties": { "label": "Remove", "enabled": true },
"on": {
"tap": [ { "action": "$remove", "key": "items", "at": { "$expr": "item_index" } } ]
}
}
]
}
]
},
"on": { "appear": [ { "action": "$set", "key": "seen", "value": true } ] },
"watch": { "items": [ { "action": "$set", "key": "draft", "value": "" } ] }
}
What it demonstrates:
- Keyed instances (contract 2.1):
keyisitem.id, so a row’s reference isdone[milk]rather thandone[0]. It keeps that identity when the list is reordered or an earlier row is removed, which is what makes an emission from it, and every report about it, still mean the same row. - Editing one element:
$appendadds a row,$updatesets a field of the row atat, and$removedrops it. Inside the template,<as>_indexis the row’s position at the moment of the tap, so a row edits and removes itself without the document knowing where it sits. Everything else about a mutation is unchanged: the same limits, the same distinct-key rule, the same end of the action list when one is rejected. - Reacting to the document’s own data: the
watchonitemsclearsdraftas part of whatever mutation changed the list, before the next action of that list runs. Mutations made inside a watch trigger no further watch, so there is no cascade to reason about. - A lifecycle binding:
appearruns when the host says the view is on screen, and again each time it returns. Nothing here is inferred by Milano; the host delivers the signal. - A host function:
formatMoney(item.cents, 'EUR')is declared by the vocabulary and computed by the app, so money is formatted by the platform’s own locale services while the document stays free of formatting rules. It is called by its bare name, where the contract’s own functions carry a$($concat,$length,$trim,$isEmpty,$str), which is why a vocabulary may declareroundbeside$roundwithout either shadowing the other. - Vectors: the mechanics here are pinned piece by piece rather than by one scenario, by
array-*,watch-*,repeat-key-*,lifecycle-*, andfunction-*inconformance/examples/.
The contact form
Name, surname, email, optional phone, and a consent checkbox that gates submission. All field composition is document-defined: adding a field is a document change, not an app release.
{
"version": "2.1.0",
"state": {
"name": "string",
"surname": "string",
"email": "string",
"phone": "string?",
"consent": "bool",
"submitted": "bool",
"error": "string"
},
"root": {
"type": "Column",
"id": "form",
"children": [
{
"type": "TextField",
"id": "name",
"properties": { "label": "Name", "value": { "$expr": "state.name" } },
"on": { "change": [ { "action": "$set", "key": "name", "value": { "$expr": "event" } } ] }
},
{
"type": "TextField",
"id": "surname",
"properties": { "label": "Surname", "value": { "$expr": "state.surname" } },
"on": { "change": [ { "action": "$set", "key": "surname", "value": { "$expr": "event" } } ] }
},
{
"type": "TextField",
"id": "email",
"properties": { "label": "Email", "value": { "$expr": "state.email" } },
"on": { "change": [ { "action": "$set", "key": "email", "value": { "$expr": "event" } } ] }
},
{
"type": "TextField",
"id": "phone",
"properties": { "label": "Phone (optional)", "value": { "$expr": "state.phone ?? ''" } },
"on": { "change": [ { "action": "$set", "key": "phone", "value": { "$expr": "event" } } ] }
},
{
"type": "Checkbox",
"id": "consent",
"properties": { "label": "I agree to be contacted", "checked": { "$expr": "state.consent" } },
"on": { "change": [ { "action": "$set", "key": "consent", "value": { "$expr": "event" } } ] }
},
{
"type": "Button",
"id": "submit",
"properties": {
"label": "Send",
"enabled": { "$expr": "state.consent && !$isEmpty($trim(state.name)) && !$isEmpty($trim(state.surname)) && !$isEmpty($trim(state.email)) && !state.submitted" }
},
"on": {
"tap": [
{
"action": "$when",
"condition": { "$expr": "state.consent && !$isEmpty($trim(state.name)) && !$isEmpty($trim(state.surname)) && !$isEmpty($trim(state.email)) && !state.submitted" },
"then": [
{
"action": "submitContact",
"name": { "$expr": "$trim(state.name)" },
"surname": { "$expr": "$trim(state.surname)" },
"email": { "$expr": "$trim(state.email)" },
"phone": { "$expr": "state.phone" },
"onSuccess": [ { "action": "$set", "key": "submitted", "value": true } ],
"onFailure": [
{
"action": "$set",
"key": "error",
"value": { "$expr": "$if(failure == 'invalid', 'Check your details and try again.', 'We could not reach the server. Try again later.')" }
}
]
}
]
}
]
}
},
{
"type": "Text",
"id": "thanks",
"properties": { "text": { "$expr": "$if(state.submitted, 'Thanks! We will be in touch.', '')" } }
},
{
"type": "Text",
"id": "problem",
"properties": { "text": { "$expr": "state.error" }, "visible": { "$expr": "!$isEmpty(state.error)" } }
}
]
}
}
What it demonstrates:
- State shape in the document, values from outside: the state data provider supplies the initial values (empty strings,
nullphone,falseflags), awaited during the asynchronous build. - The unidirectional loop: each field renders
state.xand itschangeevent runs$setwith the event payload. The renderer never writes state; the loop closes through dispatch. - Actions as capabilities:
submitContactis declared in the app’s vocabulary; documents never declare actions, so nothing reaches a handler the app did not consciously grant. A surface can narrow the set (allowActions) or override a signature (builderaction(...)) without touching the vocabulary; meaning stays with each surface’s handler. - Consent gating, twice: the
enabledexpression handles presentation (the consumer’s Button decides what disabled looks like), and the$whenguard handles semantics: atapemitted while the condition is false dispatches nothing. The gate is in the document, not in renderer logic. - Async completion:
submitContactis delivered to the host’s handler; the sequence does not wait. When the handler succeeds,onSuccesssetssubmitted, which disables the button and reveals the thank-you text through$if(...). - Failure payloads (contract 2.1):
submitContactdeclares afailureenum, so when the handler fails withinvalidorunavailable,failurebinds insideonFailureand the document chooses the message itself; a plain error from the handler, carrying no value, would be an invalid completion against this non-optional declaration, which is why the sample handler maps every error to a reason. The handler also receives the dispatch identity with the action, an idempotency key for the request it makes. - Optionality:
phoneisstring?end to end:nullfrom the provider, displayed via?? '', passed asnullto the handler when never edited. - Vector:
conformance/examples/form-contact-consent.jsonwalks the full interaction: fill fields, tap while unconsented (nothing dispatches), consent, tap, complete with success; expects the final state store, the single dispatched action (number 0), and zero occurrences. The failure branch is walked by thecompletion-failure-*vectors.
Quick actions
A horizontal strip of tiles, each an icon above a label, that records which one was tapped and then asks the host to open a screen. The list is data, not markup: one template, four tiles.
{
"version": "2.1.0",
"vocabulary": { "name": "examples", "min": "1.1.0" },
"state": {
"actions": {
"array": {
"record": {
"id": "string",
"label": "string",
"icon": { "enum": ["person", "list", "search", "edit", "settings", "help"] },
"screen": { "enum": ["profile", "catalog", "settings", "help"] }
}
}
}
},
"root": {
"type": "Column",
"id": "home",
"children": [
{
"type": "Text",
"properties": { "text": "Quick actions" }
},
{
"type": "Row",
"id": "strip",
"properties": { "spacing": 12 },
"children": [
{
"type": "$repeat",
"id": "tiles",
"items": { "$expr": "state.actions" },
"as": "tile",
"key": { "$expr": "tile.id" },
"children": [
{
"type": "Card",
"id": "tile",
"properties": {
"cornerRadius": 12,
"accessibilityLabel": { "$expr": "tile.label" }
},
"on": {
"tap": [
{
"action": "logEvent",
"event": "quick_action_tapped",
"surface": "home",
"position": { "$expr": "tile_index" }
},
{
"action": "navigate",
"screen": { "$expr": "tile.screen" }
}
]
},
"children": [
{
"type": "Column",
"children": [
{ "type": "Icon", "properties": { "name": { "$expr": "tile.icon" } } },
{ "type": "Text", "properties": { "text": { "$expr": "tile.label" } } }
]
}
]
}
]
}
]
}
]
},
"metadata": { "screen": "home" }
}
What it demonstrates:
- The template is written once:
$repeatrenders oneCardper element ofstate.actions, so adding a fifth quick action is a change to the data the app supplies, not to the document. Reordering them is the same. - Enums cross the state boundary:
iconandscreenare declared as enums in the state declaration, sotile.iconhas an enum type and satisfiesIcon’s enum property. Astringthere would be aSchemaViolation: an enum position accepts member literals and expressions of the same enum type only (expression language spec, Enum rules). This is what keeps a typo in the supplied data from reaching a renderer as an icon name nobody handles. - The tapped element’s position, in the payload:
tile_indexis the element’s index instate.actionsat dispatch time, so the third tile dispatcheslogEventwithposition2. The index is bound per instance; nothing in the document counts, and no renderer passes a position back. - Position and identity are different things: because this
$repeatcarries akey, every report about the instance names ittile[settings], nottile[2]: the identity that survives a reorder. Analytics that wants the order takestile_indexas a parameter, as this document does; analytics that wants the thing reads the reference. An unkeyed repeat has only the index, and reports it astile[2]. - A sequence, in document order: the two actions on
taprun in order, and both are dispatched with the binding captured at dispatch, sologEventcannot record one tile whilenavigateopens another. - The names here are this vocabulary’s, not the contract’s: nothing blesses
logEventornavigate. The SDK’s sample apps model the same strip against their own vocabulary and call the analytics actiontrack, with aneventmember for the tap; a document written for one app does not carry over to another with a different manifest, which is the point of a vocabulary being the app’s capability list. - Navigation is a capability, not a URL:
navigatedeclaresscreenas an enum, so a document can only ask for a screen the app declared. CompareopenUrlin the banner, where the value is astringand the handler is the last check: the enum moves that check to the gate, which is the right trade when the set of destinations is closed and known.