Mosaic one model, many lenses

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.rs holds 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.
  • groups and steps are 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 groups nor steps) — 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 rest hidden), 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 against V("<field>") (loose ==, numeric </>=) and toggles hidden; re-run on every input/change.
  • initWizard(f) — shows one [data-wfstep] at a time; data-wf-next/data-wf-back move 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.yaml and rendered in the web lens.
  • show_if is 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-stack example exercises the new shapes in a real build: PlaceOrder uses show_if (items appears once an order id is entered) + groups (Order / Flags); CancelOrder is a 2-step wizard (Which order → Why). The conformance golden for its pages.rs pins the emitted markup, so the generated Leptos is compiled in CI.

Verification

  • Unit tests (mosaic-core): the show_if subset — 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 compiled data-show-if on the conditional field, the trailing unlisted field, group order) and a steps form (step 0 visible / step 1 hidden, data-wf-next before data-wf-back); the dispatch.js carries formFieldValue/applyShowIf/initWizard + the hidden-field omission. Plan-time negative tests: show_if over a non-field is a load error; steps that don't cover every form field is a plan diagnostic.
  • Real build: the full-stack example (a hydration: full app) renders and compiles — full-stack-web (SSR) + full-stack (app) build, confirming the Leptos view! accepts the new data-show-if / data-wfstep markup; its declared e2e scenarios (REST + the full_stack_web command-form block) all pass.
  • Gates: workspace tests, clippy -D warnings, fmt, conformance re-pinned (full-stack pages.rs + every example's dispatch.js), full-stack build + e2e.