ADR 0043 — Command-form show_if, field groups, and wizard steps
- Status: accepted
- Date: 2026-10-05
- Slice: P43 (builder-roadmap: DSL-first, codegen-first, UI-first)
Context
P37 (ADR 0037) gave every command a typed web form, but the form was a flat stack: every field rendered, always visible, in declaration order. Three common form shapes were impossible to declare:
- Conditional fields — "show the card-number field only when payment method is card", "show an invoice reference only when the method is invoice". A form that always shows every field makes the user work out which fields apply.
- Visual grouping — a long form with a heading per section (billing / shipping / flags) reads better than one undifferentiated stack.
- Wizard steps — a multi-part command (pick the order, then give a reason) is easier as one step at a time than a single tall page.
These are display-side concerns: the server-side truth stays the guard +
command.validate (P36) + deserialization — the form never weakens a
check, the same posture as P37's required. So they belong in the DSL
(command.form) and the web lens, with no new runtime mechanism.
Decision
1. command.form extensions (DSL + plan)
A command form may now declare, in addition to title/submit/
fields:
form:
title: "New order"
submit: "Place order"
fields:
- field: method
- field: amount
- field: card_number
show_if: 'method == "card"' # a restricted expression over the form's fields
# OR visual groups (mutually exclusive with `steps`):
groups:
- title: "Billing"
fields: [method, amount, card_number]
- title: "Flags"
fields: [urgent]
# OR wizard steps (mutually exclusive with `groups`):
steps:
- title: "Method"
fields: [method, amount]
- title: "Details"
fields: [card_number, urgent]
show_if— a per-field visibility condition. It is a restricted subset of the tessera expression language: literals (string/number/bool), single-segment field references,!, and the binary ops==,!=,<,<=,>,>=,&&,||(parenthesization comes from the parser). A field reference must name a form field; a multi-segment path or any other node (function calls, indexing, arithmetic, …) is a load-time error.crates/mosaic-core/src/showif.rsholds the validator + the compiler to a JS expression.groups— visual sections. Each lists the fields it contains (each field in at most one group; an unknown field or an empty section is a plan error). Fields not listed in any group trail after the groups, without a heading. Groups are all visible at once.steps— a wizard. Each step lists its fields; together the steps must cover every form field exactly once (a plan-time diagnostic otherwise). One step is visible at a time; Next/Back move between them and the submit button sits on the last step.groupsandstepsare mutually exclusive (a plan-time error).
The plan (CmdFormPlan) resolves groups/steps to
CmdFormSectionPlan (title + field names, validated against the
resolved form fields) and carries each field's show_if (the validated
Expr, subset-checked at load).
2. Rendering (web codegen)
CommandPage_<Cmd> now lays the form out by shape:
- flat (neither
groupsnorsteps) — unchanged: every field, then the submit button. - groups — one heading per group + its fields, then the trailing unlisted fields, then the submit button.
- steps — one
<div data-wfstep="i">per step (step 0 visible, the resthidden), each with its heading + fields + a nav row (Back when not first, Next when not last, the submit button on the last step).
A field with a show_if is wrapped in <div data-show-if="<js>">. The
compiled JS is embedded as a Rust string literal (the {:?} keeps the
inner quotes valid in the generated Leptos view!, and consistent
between the SSR-rendered string and the client DOM — getAttribute
returns the decoded value in both paths).
3. Live visibility + wizard nav (the shared dispatch.js)
The global cmd-form handler now also, per form:
formFieldValue(f, name)— reads a field's live value (bool/number/string by control type).applyShowIf(f)— for each[data-show-if]wrapper, evaluates its JS againstV("<field>")(loose==, numeric</>=) and toggleshidden; re-run on everyinput/change.initWizard(f)— shows one[data-wfstep]at a time;data-wf-next/data-wf-backmove between them.
On submit, a field whose [data-show-if] wrapper is currently hidden is
omitted from the POST body (a hidden conditional field doesn't
apply). Fields hidden only because they sit in a non-current wizard step
still submit — a wizard collects every step before the last step's
submit fires.
Consequences
- A command form can now express conditional fields, sectioned forms,
and wizards — the three display shapes P37's flat form couldn't — all
declared in
tessera.yamland rendered in the web lens. show_ifis a deliberately small, plan-validated subset: no runtime expression interpreter ships. The expression is compiled to JS at codegen and evaluated in the browser against the live values; unknown fields / unsupported nodes fail at load, not at runtime.- No new runtime mechanism, no new CLI flag, no new route. The change is
display-side only: the server-side truth (guard,
command.validate, deserialization) is untouched, and a hidden field simply doesn't appear in the payload — the same fail-closed posture as P37's form semantics. - The
full-stackexample exercises the new shapes in a real build:PlaceOrderusesshow_if(itemsappears once an order id is entered) +groups(Order / Flags);CancelOrderis a 2-step wizard (Which order → Why). The conformance golden for itspages.rspins the emitted markup, so the generated Leptos is compiled in CI.
Verification
- Unit tests (mosaic-core): the
show_ifsubset — the JS it emits for the supported operators, and rejection of unknown fields, multi-segment paths, and unsupported nodes (function calls, indexing, unary minus, arithmetic). - Render tests (mosaic-render): a form with
show_if+groups(headings, the compileddata-show-ifon the conditional field, the trailing unlisted field, group order) and astepsform (step 0 visible / step 1hidden,data-wf-nextbeforedata-wf-back); thedispatch.jscarriesformFieldValue/applyShowIf/initWizard+ the hidden-field omission. Plan-time negative tests:show_ifover a non-field is a load error;stepsthat don't cover every form field is a plan diagnostic. - Real build: the
full-stackexample (ahydration: fullapp) renders and compiles —full-stack-web(SSR) +full-stack(app) build, confirming the Leptosview!accepts the newdata-show-if/data-wfstepmarkup; its declared e2e scenarios (REST + thefull_stack_webcommand-form block) all pass. - Gates: workspace tests, clippy
-D warnings, fmt, conformance re-pinned (full-stackpages.rs+ every example'sdispatch.js), full-stack build + e2e.