Skip to content

The editor ​

FormKitEditor is the designer shell: the toolbar every Blueprint designer shares, a searchable palette, the layout canvas, the inspector, and the Preview and Definition JSON dialogs. All state lives in a FormKitEditorController, a plain ChangeNotifier that swaps in a new, valid FormDefinition on every command. The draft, its selection, undo history, saved point (isDirty, markSaved()), changes stream and issues are a DesignerController<FormDefinition> from vyuh_kit, the session every Blueprint designer shares; it is controller.designer, and its issues are the form's design issues in the shared DesignIssue shape (a target of the field key, element id or rule id, and a path), so controller.designer.cdxIssues() reports them in the CDX vocabulary like any other kit's. The controller adds the form's own commands and what only this designer has: the step the canvas shows (undo comes back to the step an edit was made in), hover, the last refusal and the inspector's drafts. A definition the host loads with replaceDefinition is one undo step and is announced on changes, unlike designer.replace(external: true), which forgets the history and announces nothing.

On wide screens the palette, canvas and inspector sit side by side and the side panes can be resized. On narrow screens the palette and inspector become drawers over the canvas, toggled from the toolbar.

The editor's one row of chrome is formToolbar, the DesignerToolbar that the checklist, dashboard and report editors share, so the form's controls read and sit as theirs do. Below three columns the toggles that open the palette and the inspector as drawers are at its start. At its end are the form's status chip (see Form status), Undo and Redo, the code icon for the Definition JSON, the host's toolbarActions, and the Design / Preview toggle, in the toolbar's order and gaps. The toolbar is always there. The form card on the canvas holds the form itself, its title, description and layout summary, and has no controls of its own: unlike an element it has no floating pill, and it keeps its selected outline.

FormKitEditor ​

dart
KitScope<FormKit>(
  kit: kit, // the kit the controller was built with
  services: services, // the host's assets among them
  child: FormKitEditor(
    controller: controller, // FormKitEditorController(definition, kit: kit)
    actions: [...FormEditorActions.standard, startCollapsed],
    toolbarActions: [
      DesignerAction(
        id: 'publish',
        icon: Icons.publish_outlined,
        tooltip: 'Publish',
        onPressed: () async => publish(),
      ),
    ],
    onChanged: (definition) => autosave(definition),
  ),
)

What the host lends the designer comes from the KitScope<FormKit> above it, the same one its forms sit under: its services (FormHostServices), whose assets the image inspector picks pictures through and the canvas and preview show them from. The views of its own types (drawn in the preview) and the palette are parts of the kit itself. Without a scope, images can't be chosen. In debug builds the editor asserts that the scope's kit is the controller's.

ParameterTypePurpose
controllerFormKitEditorControllerRequired. Owns the definition, selection and history, and the kit: the palette, validator and rule kinds and codecs (see The kit).
actionsList<FormEditorAction>Every action the author can take to an element, in order (default FormEditorActions.standard). The selection bar, the menus and the keyboard all follow this order. The form's own Undo and Redo are always on the toolbar, and its Preview unless this list leaves FormEditorActions.preview out.
toolbarActionsList<DesignerAction>The host's own document-level actions, icons with tooltips after the code icon, such as a Publish button.
validationHostFormValidationHost?Answers the checks only the host can run while the form is previewed.
previewValuesPreviewValuesModeWhether the preview shows the values a submit would carry.
showStatusboolWhether the toolbar shows the form's status chip.
onIssuesChangedValueChanged<List<CdxIssue>>?Told the form's issues when they change; see Form status.
onChangedValueChanged<FormDefinition>?Told the definition each time it changes; see Saving.

Preview and Definition JSON ​

  • Preview opens the current definition in a FormView to try values in, checked by the controller's kit. The form's own buttons show as filling would (Cancel, Save draft if turned on, and Submit, which checks the form), and do nothing else. The toolbar above the form switches the device width and the form's access (Editable, Read-only, Disabled), so authors see each state a host can give it: read-only stays legible, and disabled mutes every field. The values are the preview's own: Reset values clears them, and they go when it closes. A Values pane shows what a submit would carry (formula and computed values in, hidden fields out) as JSON, in the same highlighted view and formatting as the Definition JSON dialog, updating as you type; it sits beside the form when the dialog has room for the chosen device width, and is a Show values toggle otherwise. Copy values copies that JSON.
  • Definition JSON, the code icon on the toolbar, opens the dialog every Blueprint designer shares (DesignerJsonDialog): the definition encoded through the controller's kit, highlighted and read-only in a CdxCodeEditor, with Copy. Import JSON reads JSON pasted or typed into a highlighted editor through the same kit; a syntax error or a refusal of the kit shows in the editor, at its line or with the path of the fault, and either changes nothing. A valid one replaces the definition as one undo step (controller.replaceDraft). The import is the author's edit and makes the draft dirty.

formToolbar(controller, readOnly: true) has no undo, redo or import, for a host that places the toolbar itself.

Saving ​

The host stores the definition; the controller tells it when there is something to store.

MemberMeaning
controller.isDirtyThe draft differs from the last saved one. An edit, an undo or a redo can change it.
controller.markSaved()Records the current draft as saved. History is kept, and undoing back to this point is clean.
controller.changesA Stream<FormDefinition> that fires each time the definition changes: an edit, an undo, a redo, or a definition the host loads with replaceDefinition. It never fires for the selection, the hover, the step shown or a refused command.
FormKitEditor(onChanged:)The same signal as a callback.
dart
final class Designer extends StatefulWidget {
  const Designer({required this.definition, super.key});

  final FormDefinition definition;

  @override
  State<Designer> createState() => _DesignerState();
}

final class _DesignerState extends State<Designer> {
  late final _controller = FormKitEditorController(widget.definition);

  Future<void> _save() async {
    await store(_controller.kit.encodeString(_controller.definition));
    _controller.markSaved();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => KitScope<FormKit>(
    kit: _controller.kit,
    child: ListenableBuilder(
      listenable: _controller, // notifies when isDirty changes too
      builder: (context, _) => Column(
        children: [
          FilledButton(
            onPressed: _controller.isDirty ? _save : null,
            child: const Text('Save'),
          ),
          Expanded(child: FormKitEditor(controller: _controller)),
        ],
      ),
    ),
  );
}

The saved point is a definition in the undo history, so isDirty follows the history: typing a value away and back by hand is two edits, while undoing them is clean. A definition the host opens with replaceDefinition is the saved one. The Definition JSON dialog's import and controller.clearAll() are the author's edits and make the draft dirty.

Controller commands ​

dart
final controller = FormKitEditorController(
  deviationIntake,
  kit: designerKit, // see The kit; the standard kit when left out
);

Commands that can be refused return bool. A refused command leaves the definition unchanged and puts the reason in lastRefusal. Every accepted command is one undo step, and listeners are notified.

AreaMembers
Statedefinition, isDirty, markSaved(), changes, selectedId, hovered (a ValueListenable<String?>), lastRefusal, canUndo, canRedo, select(id), hover(id), designIssues, liveIssues, reports (see Form status), revealed, reveal(place), openIssue(issue), dismissReveal()
Form and fieldssetFormDetails({key, title, description}), setFieldTitle(key, title), renameField(key, newKey), updateField(key, edit), updateNode(id, edit), renameNode(id, newId), setRepeatLimits(id, {minItems, maxItems}), setFormActions(actions), replaceDefinition(next) (a definition the host loads: saved), replaceDraft(next) (the author's own change: dirty), clearAll()
StepscurrentStepId, currentStep, showStep(id), addStep(), removeStep(id), moveStep(id, toIndex), setStepper(stepper)
Drag and dropevaluateDrop(payload, target), drop(payload, target) with a DropPayload (NewField, NewNode, MoveNode) and a DropTarget
RowssetRowColumns(rowId, columns), moveRowBoundary(rowId, leftIndex, delta), setCellSpan(rowId, index, span), equalizeRow(rowId), unwrapRow(rowId), wrapInRow(ids)
GridsresizeGridItem(gridId, childId, rect), tidyGrid(gridId), setGridCells(gridId, cellSize:, rowGap:, columnGap:), unwrapGrid(gridId); setGridWidth(gridId, width) and gridOnCanvas(grid) for the columns the canvas shows a grid with
Structureduplicate(id), delete(id), wrapInGroup(ids), ungroup(id), makeRepeating(groupId), makePlainGroup(groupId), moveWithin(id, delta), moveOut(id), moveIntoNext(id). Deleting, ungrouping or unwrapping what a rule uses is refused with "Used by 2 rules".
RulesaddRule(), updateRule(id, edit), removeRule(id), duplicateRule(id), moveRule(id, delta), updateWhen(id, expression), updateEffect(id, index, next), addEffect(id, effect), ruleById(id)
Historyundo(), redo(), plus commit(result) for app commands built on the pure edits
dart
if (!controller.setCellSpan('deviation_row', 0, 8)) {
  debugPrint(controller.lastRefusal); // why the spans don't fit
}
controller.setRowColumns('material_row', 6);
controller.setRepeatLimits('materials', maxItems: (value: 20));
controller.updateField(
  'title',
  (field) => field.copyWith(helpText: 'One line; details go in What happened.'),
);
controller.undo();

The layout rules behind these commands (form_row_rules, form_grid_rules, form_tree_edits, rule_edits and others in vyuh_form_types) are pure functions that return a LayoutResult: Applied or Refused with a reason. commit applies such a result through the one history path every command uses.

Undo and redo are bound to ⌘Z / ⇧⌘Z (Ctrl on Windows and Linux) on the canvas, and to buttons on the editor's toolbar, which are disabled while there is nothing to undo or redo. Dispose the controller with its owning State.

Esc cancels any drag in progress: a palette entry, an element or grid item being moved, a grid item's resize handle or a row boundary. The drop preview goes, nothing is committed, the element stays where it was and nothing reaches the history. That Esc is used up; without a drag, Esc selects the parent as before.

Every element on the canvas drags by its whole card; there is no grip. A mouse or stylus press becomes a drag once the pointer moves past the device's hit slop (1 px for a mouse), so a click still selects; a finger drags after a press-and-hold, so a swipe over the canvas still scrolls it. The move cursor shows over every element, and starting a drag focuses the canvas, so the Esc that cancels it reaches no other input. The innermost element under the pointer is the one that moves: a press on a child moves the child, and a press on a container's own surface (its header, padding, the gaps between its children or its empty drop area) moves the container. A container's empty area still takes drops. Resize handles, row boundaries and the selection bar keep their own drags.

A grid on the canvas spans its full width, as many of its cells as fit, with one spare row below to drop or resize into. It grows down as items are placed there and shrinks back to its items; it never grows sideways past the canvas, and a resize stops at its right edge, saying so. Its edits are made against the columns shown, so its saved columns is the width it was last designed at. The inspector's Layout tab sets its cell size (32, 48 or 64 px) in one undo step.

App actions ​

A FormEditorAction declares where it applies, why it is unavailable, and what it does. The editor takes one ordered list of them, FormKitEditor(actions:), which feeds the selection bar, the ⋯ and context menus and the keyboard, all in its order (see The kit).

dart
final startCollapsed = FormEditorAction(
  id: 'acme.start-collapsed',
  title: 'Start collapsed',
  icon: Icons.unfold_less,
  group: 'Group',
  appliesTo: (s) => s.node is FormGroup,
  availability: (s) => switch (s.node) {
    FormGroup(collapsible: false) =>
      const ActionAvailability.refused('Make the group collapsible first.'),
    FormGroup(initiallyCollapsed: true) =>
      const ActionAvailability.unavailable('The group already starts collapsed.'),
    _ => ActionAvailability.available,
  },
  perform: (context, controller, s) {
    controller.updateNode(
      s.nodeId!,
      (node) => (node as FormGroup).copyWith(initiallyCollapsed: true),
    );
  },
);
// FormKitEditor(
//   controller: controller,
//   actions: [...FormEditorActions.standard, startCollapsed],
// )
ParameterMeaning
idStable kebab-case id.
title, icon, groupHow it is listed. Groups keep the order in which they first appear.
appliesToWhether it is listed for a selection.
availabilityAn ActionAvailability: available; unavailable([reason]), disabled and silent on its key; or refused(reason), disabled and saying why on its key.
performRuns the action with a BuildContext under the designer (for a dialog), the controller and the selection.
shortcutsKey bindings, as ShortcutActivators. The first action in the list whose key matches runs.
keysAnywhereIts shortcuts work whatever is selected, though it is listed only where it applies (Undo and Redo).
pinnedPinned actions come first, on the selection bar. The form has none: its Undo, Redo and Preview are on the toolbar.
destructiveShown in the error colour.
labelA selection-dependent title, such as "Columns: 6".

FormEditorSelection gives the action the definition, the selected nodeId (null for the form), the node and its location, the kit, and canUndo and canRedo. actionsFor(actions, selection) returns those that apply, pinned first, then by group, each in the list's order.

FormEditorActions.standard lists, in order: undo, redo and preview (the Form group, drawn on the toolbar, where their keys and the context menu also reach them), then duplicate, delete, wrap-in-row, wrap-in-group, ungroup, make-repeating, make-plain-group, equal-spans, row-columns, unwrap-row, tidy-placements and unwrap-to-flow, in the groups Edit, Wrap, Group, Row and Grid. Each is also a field of FormEditorActions (FormEditorActions.preview), for a host composing its own list. An app action that applies to the form (appliesTo: (s) => s.nodeId == null) is listed in the form's context menu and answers its key; a button for it on the toolbar is a DesignerAction in toolbarActions.

Form status ​

controller.designIssues is the list of everything the form's definition holds that is worth a look, worked out once per definition:

IssueAbout (target)Severity
FormValidationDesignIssue(fieldKey, index): a validation that can't be used, such as a pattern that doesn't compileIssueOnFielderror; a warning when its kind isn't registered
FormRuleDesignIssue(ruleId, place): a rule, condition or effect that can't be usedIssueOnRuleerror; a warning when its kind isn't registered
FormStructureDesignIssue(target): an image with nothing chosen; a new form with no fields yet has noneIssueOnNodewarning

Every issue has a message, a severity (FormIssueSeverity.error or .warning) and a target: IssueOnForm, IssueOnField, IssueOnNode or IssueOnRule. issues.errors, issues.warnings and issues.on(target) count and filter them, and issues compare by value. A definition that can't be used as it stands, such as a duplicate key, is refused when it is built, so it never reaches the list. That includes a formula field that doesn't compile against the fields it may read: the formula editor shows why as it is typed, and the edit is refused. A rule's formula that doesn't compile is a rule issue.

Every error on screen ​

The designer also shows errors the definition doesn't hold, because the value that would cause them is never committed: a field key being typed that is already in use, a formula draft that doesn't compile, a minimum above the maximum, a grid range that would overlap, or why the Rules or Validation tab refused an edit. Each display reports its error to controller.reports (an EditorIssues) as a FormDraftDesignIssue(target, message), an error, and clears it once it is fixed or the display goes away:

dart
controller.reports.report(
  (target: const IssueOnField('batch'), source: 'general//key'),
  (issue: issue, at: const FormEditorReveal(nodeId: 's-batch', propertyId: 'key')),
);
controller.reports.report(key, null); // fixed

EditorIssues notifies no one, so a display may report from anywhere, a build included; read the issues through liveIssues. FormPropertyField reports a refused click (a toggle or segment the rules refuse) and a failed image pick. Its place says where it shows, such as a rule's card (FormEditorReveal(tab: FormInspectorTab.rules, ruleId: id)) or a validator's.

controller.liveIssues is the one aggregate: designIssues, then everything reported. It is a ValueListenable. A change to the form (a commit, undo or redo) shows at once, so a card never shows an issue meant for another after a rename, removal or reorder. The reports are sampled about 250 ms after they last change, and at least every second during a long burst, so a burst of keystrokes gives one update and nothing flickers. Everything that counts issues reads it: the status, a validator card, a rule card's count and its rows, and the dot on a canvas card (liveIssues.at(nodeId): a field's issues on its slot, an element's on it, the form's and the rules' on the form card). What a card shows and what the status counts always agree. Refusals during a gesture, such as a drop that doesn't fit, show only while dragging and are not issues.

FormStatusChip(controller: controller) shows the status of liveIssues: a green check and "No issues", or "2 errors · 1 warning" in the colour of the worst. Its label reads "Issues: 2 errors · 1 warning". Tapped, it lists every issue, errors first, by what it is about ("Order total", "Rule: Escalate urgent orders") and its message. Choosing one calls controller.openIssue(issue): the designer selects the target, opens the inspector tab where it is fixed (Validation for a validation, Rules with the rule expanded for a rule, General otherwise), brings the card into view, for a draft focuses the property that shows it, and, when the panes are drawers, opens the inspector's drawer. Pass onOpenIssue to handle the choice yourself.

The editor's toolbar shows the chip, in the place of the shared status that the other designers' toolbars draw from designer.issues, because this one also counts the drafts on screen and opens each issue where it is fixed; a host can put it anywhere else it has the controller. Gate publishing on designIssues, the definition's own: a draft on screen isn't saved.

dart
AppBar(
  actions: [
    FormStatusChip(controller: controller),
    ListenableBuilder(
      listenable: controller,
      builder: (context, _) => FilledButton(
        onPressed: controller.designIssues.errors.isEmpty ? publish : null,
        child: const Text('Publish'),
      ),
    ),
  ],
)

A host that shows the designer's issues in its own status wraps the designer in a CdxIssueSink (from vyuh_cdx_ui). The sink hears the form's issues as CdxIssues (a message, a severity, what it is about and a reveal that calls controller.openIssue), after the frame in which they change, and the toolbar leaves out its chip. FormKitEditor(onIssuesChanged:) hears the same issues without a sink. issues.toCdxIssues(controller) maps liveIssues for a host that has the controller.

dart
final class IssueDesigner extends StatefulWidget {
  const IssueDesigner({required this.controller, super.key});

  final FormKitEditorController controller;

  @override
  State<IssueDesigner> createState() => _IssueDesignerState();
}

final class _IssueDesignerState extends State<IssueDesigner> {
  List<CdxIssue> _issues = const [];

  @override
  Widget build(BuildContext context) => Column(
    children: [
      // In the host's toolbar; `_issues.hasErrors` blocks Publish.
      CdxIssueStatus(issues: _issues),
      Expanded(
        child: CdxIssueSink(
          onIssues: (issues) => setState(() => _issues = issues),
          child: FormKitEditor(controller: widget.controller),
        ),
      ),
    ],
  );
}

controller.reveal(FormEditorReveal(nodeId:, tab:, ruleId:, validationIndex:, propertyId:)) takes the author anywhere the same way, the form itself included (nodeId: null); FormEditorReveal.issue(definition, issue) is where an issue is fixed. The reveal stays in revealed until the selection moves, the form changes or dismissReveal() is called; picking another inspector tab calls it, so the inspector never jumps back to a place the author has left.

Typed values ​

Every text input in the inspector (text, names, numbers, lengths of time, rich text, formulas, choice labels and values) commits the same way. What is typed is a draft until the author leaves it: Enter in a single-line input, Tab, a click anywhere else, Escape, selecting another element, switching inspector tab, or closing the drawer. Escape leaves the input and commits, like every other way out; nothing typed is thrown away unless the author asks. The draft is written to the element it was typed for, whatever is selected by then. Enter starts a new line in a multi-line input.

Emptying an optional property (a hint, help text, description, callout title, alt text, a number limit) clears it. A draft that can't be saved, such as an emptied title or key, a name with a space or already in use, text that isn't a number, or a formula that doesn't compile, is never reverted silently. It stays in its input with why and a Revert action, and controller.drafts (PropertyDrafts) holds it, so it is still counted in the status after the author selects another element and shows again when they come back. It is reported as a FormDraftDesignIssue at its property, so choosing it in the status takes the author back to it. A draft belongs to what it was typed for, never to a position: a validator's setting follows its validation when it moves, a choice's label and value follow the choice, and a rule's operand or effect value is dropped when its condition or effect is removed or becomes another one. It is dropped once it is saved, reverted, or its element, validation, choice, rule part or property is gone. A formula's draft counts only the errors its field shows, so nothing while typing or for a token still being typed.

Each property shows one message in one place. A name is checked as it is typed; a number or a length of time is checked when it is committed, so a half-typed - or 1d 4 shows nothing. Under a name, number or length of time a line is kept for its message: the confirmation ("✓ Unique, valid identifier") or why the draft can't be used, with Revert. The line is kept while it is empty, so nothing below moves as the author types, and a longer message ends in an ellipsis, whole in a tooltip. A formula shows its own message, and the property adds nothing under it.

A list of words, such as a validator's allowed values or currencies, adds the word being typed on Enter and whenever the author leaves its input, the same ways as a draft, so a word is never lost for want of Enter.

A new controller given to the editor (the app opened another form) starts a new inspector bound to it. A draft still being typed when that happens goes to the form it was typed for, never to the new one.

The definition never holds a draft, so a host that saves the form saves only what is committed. controller.drafts.count is a ValueListenable<int> of the drafts that can't be saved yet, and controller.drafts.held lists each with where it shows, so a host can say so before it saves:

dart
ValueListenableBuilder(
  valueListenable: controller.drafts.count,
  builder: (context, unsaved, _) => Text(
    unsaved == 0 ? 'All changes saved' : '$unsaved unsaved edits',
  ),
)

Palette ​

The palette lists PaletteEntrys under PaletteCategory headings. Both are the shared palette parts of vyuh_kit; the form designer's entries make a DropPayload in a FormPaletteContext.

text
const PaletteEntry<FormPaletteContext, DropPayload>({
  required String id,
  required String label,
  required DropPayload Function(FormPaletteContext context) create,
  String? glyph,                      // '✎', shown where there is no icon
  IconData? icon,                     // Icons.draw
  String? category,                   // the id of a PaletteCategory
  List<String> keywords = const [],
  String? description,
});

create returns a NewField(field) or NewNode(node) payload. Its FormPaletteContext offers nextKey(prefix) and nextId(prefix), which return names not yet used in the definition. Entry ids must be unique.

Entries and categories come from the controller's kit: each extension carries its PaletteCategory and PaletteEntry parts (extension.withParts([...])), and the palette shows them in the kit's order (see The kit). The standard categories, in order, are FormCategories.standard: Layout, Text & numbers, Choice & reference, Date & time, Media & files (Photo, File and Signature), Computed, Content. An app category is PaletteCategory(id:, label:) and shows a "custom" tag. FormEntries.field and FormEntries.content build the entries of a field type and a content type. The standard entries are the PaletteEntrys of FormEditorStandard.parts.

See Custom fields for a complete entry.

Formulas ​

A formula field's Expression and a Set value formula use the formula field of vyuh_expression_ui (ExpressionFormulaField). The text is in the syntax of vyuh_expression, read by parseFormula against the fields the formula may read and the type it must give. The editor tells the field which fields those are, read live from the controller's definition each time it changes (formulaInputsFor):

  • Insert field (the {} button, or All fields… after typing $) opens a CDX popover that lists every field under its group title, with its label, $key and type. Search matches a label or key anywhere. A field the formula can't read, such as an attachment, a multiple choice or a field of another repeating group, is muted with the reason and can't be picked.
  • Typing $ completes from the fields whose key or label contains what follows, and a word completes to a function. The completions float under the input, so nothing below moves.
  • Errors wait until typing pauses or the editor loses focus, underline the part of the text they are about, and name fields by label: "Unit price is a number; it can’t be compared with text." A field that can't be read says why; a misspelt key suggests the closest field.
  • A formula that checks out shows its result type and the fields it reads: "Number · reads Quantity, Price".
  • The editor keeps one height while the formula is typed. Its one message, the first error or the result, sits in a two-line slot under the input in the inspector's message style, whole in a tooltip when it is longer, with Revert at its end for a draft that can't be saved.

A formula is evaluated three-valued: an empty field makes the result empty only where the formula reads it, so if($rush, $a, $b) with $b empty still gives $a when $rush is on. See Rules and formulas.

A formula field reads the fields of its own scope; a rule's formula reads any field, and the rule runs per entry of the repeating group whose fields it reads.

The palette's Number allows decimals; turn off Allow decimals for whole numbers. A whole-number input that refuses a . says so under it.

Validators ​

The designer offers the validator kinds of the controller's kit (FormKitEditorController(definition, kit: kit)), in its order, with addValidation, replaceValidation, moveValidation and removeValidation on the controller. See Custom validators.

Rules ​

Rules live on the Form inspector's Rules tab, after General and Layout.

  • The list. One card per rule: its switch, its title (or a one-line summary such as "When Severity is Major or Critical → show CAPA, require CAPA owner"), how many issues it has, and a ⋯ menu to duplicate, move or delete it. Add rule adds one with an empty condition and effect, opened. With no rules, the tab explains what they do.
  • When. A rule's when is an expression, edited on vyuh_expression_ui: a Conditions tree (All / Any of these, one row per test: a field picker by label, an operator filtered by the field's type, and an operand typed by the field: a number, a date, the field's choices, a toggle, text, or, with the swap icon, another field of the same type; Not turns a row around; Add condition, Add group, and Add check for the kit's registered facts) and a Formula view of the same expression as text with its diagnostics. A tree holding a check has no text, and the Formula view says so. An empty value makes a test unknown and a rule fires only when its expression is definitely true: to cover an empty field, add an "is empty" row under Any.
  • Then. One row per effect: the action (Show, Hide, Enable, Disable, Require, Set value, Apply validation, and the app's effects) and a picker of the fields and elements that action fits. Set value adds a value or a formula; Apply validation adds the target field's validations by title.
  • Every edit is one undo step through the controller, and a row that can't be used shows why under it.
  • A field's General tab ends with Rules (n) → while rules read or change it; it opens the Rules tab filtered to the field, with a chip to clear the filter.
  • On the canvas, an element rules change carries a small bolt; its tooltip lists the rules.
  • The preview runs the rules live.

See Rules and formulas for the model and Custom rule kinds to add your own.