Screens

Example screens

members.registrations

admin

Route /mitglieder/anmeldungen/:id · 7 components: page-header, chip-group, list, empty-state, kv, alert, button · actions: approve (irreversible), reject (reversible), edit (safe)

Screen definition
{
  "$schema": "../../packages/ui/screens/screen.schema.json",
  "id": "members.registrations",
  "version": 1,
  "app": "admin",
  "route": "/mitglieder/anmeldungen/:id",
  "title": "Anmeldungen",
  "nav": { "label": "Mitglieder", "icon": "users", "order": 2, "tab": true },
  "requires": ["members.registrations"],
  "data": {
    "registrations": { "source": "members.registrations.list", "params": { "status": { "$bind": "params.status" } } },
    "selected": { "source": "members.registrations.get", "params": { "id": { "$bind": "params.id" } } }
  },
  "actions": {
    "approve": {
      "handler": "members.approveRegistration",
      "risk": "irreversible",
      "confirm": {
        "title": { "$text": "{selected.name} freigeben?" },
        "body": "Danach geschieht Folgendes:",
        "consequences": { "$bind": "selected.approvalEffects" },
        "confirmLabel": "Freigeben",
        "tone": "primary"
      },
      "success": { "$text": "{selected.name} freigegeben" }
    },
    "reject": { "handler": "members.rejectRegistration", "risk": "reversible", "success": "Anmeldung abgelehnt" },
    "edit": { "handler": "members.editRegistration", "risk": "safe" }
  },
  "body": [
    {
      "component": "page-header",
      "data": {
        "title": "Anmeldungen",
        "meta": { "$text": "{registrations.openCount} warten auf Freigabe · Formular online" },
        "actions": [{ "label": "Formular ansehen", "icon": "external-link", "href": "../examples/public-registration.html" }]
      }
    },
    {
      "component": "chip-group",
      "data": {
        "label": "Status",
        "mode": "single",
        "chips": [
          { "value": "pending", "label": "Offen", "count": { "$bind": "registrations.openCount" }, "pressed": true },
          { "value": "approved", "label": "Freigegeben", "count": { "$bind": "registrations.approvedCount" } },
          { "value": "rejected", "label": "Abgelehnt", "count": { "$bind": "registrations.rejectedCount" } }
        ]
      }
    },
    {
      "layout": "split",
      "children": [
        {
          "component": "list",
          "if": { "$bind": "registrations.items.length" },
          "data": {
            "label": "Offene Anmeldungen",
            "items": {
              "$each": "registrations.items",
              "as": "r",
              "map": {
                "id": { "$bind": "r.id" },
                "title": { "$bind": "r.name" },
                "meta": { "$text": "{r.age} Jahre · eingegangen {r.receivedLabel}" },
                "href": { "$text": "?screen=members.registrations&id={r.id}" },
                "avatar": { "$bind": "r.initials" },
                "current": { "$bind": "r.selected" },
                "badge": { "$if": { "$bind": "r.flag" }, "then": { "label": { "$bind": "r.flag.label" }, "tone": { "$bind": "r.flag.tone" } } }
              }
            }
          }
        },
        {
          "component": "empty-state",
          "if": { "$not": { "$bind": "registrations.items.length" } },
          "data": { "icon": "check-circle", "title": "Keine offenen Anmeldungen", "text": "Neue Anmeldungen aus dem Online-Formular erscheinen hier." }
        },
        {
          "layout": "card",
          "if": { "$bind": "selected" },
          "children": [
            { "component": "page-header", "data": { "title": { "$bind": "selected.name" }, "meta": { "$bind": "selected.statusLabel" } } },
            {
              "component": "kv",
              "data": {
                "items": [
                  { "term": "Geburtsdatum", "value": { "$text": "{selected.birthDateLabel} ({selected.age} Jahre)" } },
                  { "term": "Erziehungsberechtigt", "value": { "$bind": "selected.guardian" } },
                  { "term": "Gewünschte Gruppe", "value": { "$bind": "selected.group" } },
                  { "term": "Mitgliedschaft", "value": { "$bind": "selected.membership" } },
                  { "term": "Fotos", "value": { "$bind": "selected.photoConsent" } }
                ]
              }
            },
            {
              "component": "alert",
              "if": { "$bind": "selected.suggestion" },
              "data": { "tone": "info", "title": { "$bind": "selected.suggestion.title" }, "body": { "$bind": "selected.suggestion.body" } }
            },
            {
              "layout": "cluster",
              "justify": "end",
              "children": [
                { "component": "button", "data": { "label": "Ablehnen", "variant": "ghost", "action": "reject", "actionId": { "$bind": "selected.id" } } },
                { "component": "button", "data": { "label": "Bearbeiten", "action": "edit", "actionId": { "$bind": "selected.id" } } },
                { "component": "button", "data": { "label": "Freigeben", "variant": "primary", "action": "approve", "actionId": { "$bind": "selected.id" } } }
              ]
            }
          ]
        }
      ]
    }
  ]
}

performance.programme

member

Route /plaene/:planId · 5 components: page-header, progress, alert, list, note · actions: done (reversible), set (reversible), reset (reversible), details (safe)

Screen definition
{
  "$schema": "../../packages/ui/screens/screen.schema.json",
  "id": "performance.programme",
  "version": 1,
  "app": "member",
  "route": "/plaene/:planId",
  "title": {
    "$bind": "programme.title"
  },
  "nav": {
    "label": "Pläne",
    "icon": "list",
    "order": 2,
    "tab": true
  },
  "requires": [
    "performance.programmes"
  ],
  "data": {
    "programme": {
      "source": "performance.programmes.today",
      "params": {
        "planId": {
          "$bind": "params.planId"
        }
      },
      "offline": true
    }
  },
  "actions": {
    "done": {
      "handler": "performance.completeExercise",
      "risk": "reversible",
      "success": "Übung fertig"
    },
    "set": {
      "handler": "performance.completeSet",
      "risk": "reversible",
      "success": "Satz geschafft"
    },
    "reset": {
      "handler": "performance.resetExercise",
      "risk": "reversible",
      "success": "Zurückgesetzt"
    },
    "details": {
      "handler": "performance.openExercise",
      "risk": "safe"
    }
  },
  "body": [
    {
      "component": "page-header",
      "data": {
        "title": {
          "$bind": "programme.title"
        },
        "meta": {
          "$text": "Woche {programme.week} von {programme.weeks} · {programme.doneToday} von {programme.total} Übungen heute"
        },
        "back": {
          "label": "Pläne",
          "href": "#"
        }
      }
    },
    {
      "component": "progress",
      "data": {
        "label": "Heute",
        "value": {
          "$bind": "programme.doneToday"
        },
        "max": {
          "$bind": "programme.total"
        },
        "text": {
          "$text": "{programme.doneToday} von {programme.total} Übungen"
        },
        "id": "today-progress"
      }
    },
    {
      "component": "alert",
      "if": {
        "$bind": "programme.focus"
      },
      "data": {
        "tone": "warning",
        "title": {
          "$bind": "programme.focus"
        }
      }
    },
    {
      "component": "list",
      "data": {
        "label": "Deine Übungen",
        "gestures": "primary",
        "swipeHint": {
          "id": "training-swipe",
          "title": "So geht es",
          "items": [
            "Nach rechts wischen: Übung fertig",
            "Nach links wischen: ein Satz geschafft",
            "Menü (⋯): Details und Zurücksetzen"
          ]
        },
        "items": {
          "$each": "programme.exercises",
          "as": "ex",
          "map": {
            "id": {
              "$bind": "ex.id"
            },
            "title": {
              "$bind": "ex.title"
            },
            "meta": {
              "$bind": "ex.scheme"
            },
            "done": {
              "$bind": "ex.done"
            },
            "progress": {
              "done": {
                "$bind": "ex.setsDone"
              },
              "total": {
                "$bind": "ex.sets"
              }
            },
            "quickAction": {
              "action": "done",
              "label": "Fertig",
              "icon": "check"
            },
            "swipe": {
              "mode": "commit",
              "start": {
                "action": "done",
                "label": "Übung fertig",
                "icon": "check",
                "tone": "success"
              },
              "end": {
                "action": "set",
                "label": "Satz geschafft",
                "icon": "plus",
                "tone": "accent"
              }
            },
            "actions": [
              {
                "action": "done",
                "label": "Übung fertig",
                "icon": "check"
              },
              {
                "action": "set",
                "label": "Ein Satz geschafft",
                "icon": "plus"
              },
              {
                "action": "details",
                "label": "Details, Bilder, Video",
                "icon": "info"
              },
              {
                "action": "reset",
                "label": "Zurücksetzen",
                "icon": "undo"
              }
            ]
          }
        }
      }
    },
    {
      "component": "note",
      "if": {
        "$bind": "programme.coachNote"
      },
      "data": {
        "author": {
          "$bind": "programme.coachNote.author"
        },
        "dateLabel": {
          "$bind": "programme.coachNote.dateLabel"
        },
        "text": {
          "$bind": "programme.coachNote.text"
        }
      }
    }
  ]
}

training.today

trainer

Route /heute · 5 components: page-header, action-tile, badge, save-status, list · actions: markAbsent (reversible), addNote (safe)

Screen definition
{
  "$schema": "../../packages/ui/screens/screen.schema.json",
  "id": "training.today",
  "version": 1,
  "app": "trainer",
  "route": "/heute",
  "title": "Heute",
  "nav": {
    "label": "Heute",
    "icon": "home",
    "order": 1,
    "tab": true
  },
  "requires": [
    "training.attendance"
  ],
  "data": {
    "today": {
      "source": "training.sessions.today",
      "offline": true
    },
    "current": {
      "source": "training.sessions.attendance",
      "params": {
        "id": {
          "$bind": "today.next.id"
        }
      },
      "offline": true
    }
  },
  "actions": {
    "markAbsent": {
      "handler": "training.markAbsent",
      "risk": "reversible",
      "success": "Als abwesend markiert"
    },
    "addNote": {
      "handler": "training.addNote",
      "risk": "safe"
    }
  },
  "body": [
    {
      "component": "page-header",
      "data": {
        "title": {
          "$text": "Hallo {user.firstName}"
        },
        "meta": {
          "$text": "{today.dateLabel} · {today.count} Trainings"
        }
      }
    },
    {
      "component": "action-tile",
      "if": {
        "$bind": "today.next"
      },
      "data": {
        "href": "#praesenz",
        "eyebrow": {
          "$text": "Jetzt · {today.next.timeLabel}"
        },
        "title": "Präsenz",
        "icon": "chevron-right",
        "meta": {
          "$text": "{today.next.className} · {today.next.roleLabel}"
        }
      }
    },
    {
      "layout": "section",
      "id": "praesenz",
      "title": {
        "$bind": "current.className"
      },
      "children": [
        {
          "layout": "cluster",
          "justify": "between",
          "children": [
            {
              "component": "badge",
              "data": {
                "label": {
                  "$text": "{current.presentCount} von {current.total} da"
                },
                "tone": "info"
              }
            },
            {
              "component": "save-status",
              "data": {
                "state": {
                  "$bind": "current.sync.state"
                },
                "count": {
                  "$bind": "current.sync.pending"
                }
              }
            }
          ]
        },
        {
          "component": "list",
          "data": {
            "label": {
              "$text": "Teilnehmende {current.className}"
            },
            "items": {
              "$each": "current.students",
              "as": "s",
              "map": {
                "id": {
                  "$bind": "s.id"
                },
                "title": {
                  "$bind": "s.name"
                },
                "meta": {
                  "$bind": "s.note"
                },
                "avatar": {
                  "$bind": "s.initials"
                },
                "belt": {
                  "label": {
                    "$bind": "s.belt.label"
                  },
                  "color": {
                    "$bind": "s.belt.color"
                  }
                },
                "done": {
                  "$not": {
                    "$bind": "s.present"
                  }
                },
                "actions": [
                  {
                    "action": "markAbsent",
                    "label": "Als abwesend markieren",
                    "icon": "x"
                  },
                  {
                    "action": "addNote",
                    "label": "Notiz hinzufügen",
                    "icon": "edit"
                  }
                ],
                "swipe": {
                  "mode": "reveal",
                  "end": {
                    "action": "markAbsent",
                    "label": "War nicht da",
                    "icon": "x",
                    "tone": "danger"
                  }
                }
              }
            }
          }
        }
      ]
    },
    {
      "layout": "section",
      "id": "spaeter",
      "title": "Später",
      "if": {
        "$bind": "today.later.length"
      },
      "children": [
        {
          "component": "list",
          "data": {
            "label": "Weitere Trainings heute",
            "items": {
              "$each": "today.later",
              "as": "t",
              "map": {
                "id": {
                  "$bind": "t.id"
                },
                "title": {
                  "$bind": "t.title"
                },
                "meta": {
                  "$bind": "t.meta"
                },
                "href": "#"
              }
            }
          }
        }
      ]
    }
  ]
}

A screen is a small JSON file, not hand-written JS. It says which components appear, with which data, in which layout, and which actions they can trigger. The engine (packages/ui/js/screen.js) renders it, validates every component's data against that component's contract, and wires actions with the safeguard their risk class requires.

Feature code is left with what only code can do: loading data through the SDK (the screen names the sources) and the action handlers.

File

<feature>.<screen>.json, validated by packages/ui/screens/screen.schema.json:

FieldPurpose
id<feature>.<screen>, e.g. members.registrations. The feature prefix decides which distributions include the screen.
appadmin · trainer · member · public · screens · operator: the shell it renders in
routeURL inside the app; :id segments become params.id
title, navPage title; label, icon, order and tab-bar flag for the navigation
requiresCapabilities (entitlement ∧ setting ∧ flag ∧ permission). Without them the screen doesn't exist for the user.
dataNamed data sources: { "registrations": { "source": "members.registrations.list", "params": {…} } }. The app loads them through the SDK; results are bound by name. offline: true caches them for the Trainer/Member apps.
actions{ "approve": { "handler": "members.approveRegistration", "risk": "irreversible", "confirm": {…}, "success": "…" } }
bodyThe node tree

Nodes

  • Component node: { "component": "list", "data": { … } }. Any component from the reference (../reference/index.html); data must satisfy its contract.
  • Layout node: { "layout": "stack" | "cluster" | "grid" | "split" | "section" | "card", "children": [ … ] }, with title, gap, justify and min where they apply.
  • Any node may have "if": <expression> and "requires": ["capability"].

Expressions (inside data, title, if, action options)

ExpressionResult
{ "$bind": "selected.name" }Value at a path in the context (data sources, params, user, target)
{ "$text": "Hallo {user.firstName}" }String with {path} placeholders
{ "$t": "members.title", "args": {…} }Translation from the module's catalogue
{ "$fmt": "date" | "datetime" | "number" | "chf" | "percent", "value": <expr> }Formatted with Intl (de-CH by default)
{ "$each": "registrations.items", "as": "r", "map": { … } }Maps an array; inside the template, r.… is bound
{ "$if": <expr>, "then": …, "else": … }, { "$not": <expr> }Conditions

Values that resolve to undefined are dropped, so optional component fields stay optional.

There is no way to put HTML in a screen. Every string ends up in a component's escaped output (a test proves it).

Actions and risk

Components emit data-action="<name>" (buttons via action, list menus via actions[].action, proposals). The nearest data-id is the target. The engine then:

riskEngine behaviour (UX guide §5)
safeCalls the handler; shows success if given
reversibleCalls the handler; shows a toast with Rückgängig if the handler returns { undo }
irreversibleShows a confirm dialog first (confirm.title, body, consequences[], confirmLabel), then calls the handler

Handlers are registered by the feature: handlers["members.approveRegistration"] = async ({ id, ctx }) => { … return { undo } }.

Checks

  • checkScreen(screen, components) checks the structure, validates literal component data fully, flags unknown fields in data that contains expressions, finds actions that are used but not declared, and finds missing risk classes.
  • packages/ui/tests/screens.test.js runs checkScreen on every screen and renders it with its fixture (<id>.data.json), with every component contract validated.

Where screens live

  • Today: styleguide/screens/*.json (examples, viewable at styleguide/screens/view.html?screen=<id>).
  • Target: features/<feature>/screens/*.json (ARCHITECTURE.md §9.3), included in a distribution with their feature. Because they are data, they can also be stored as versioned documents in Wren (labels draft/published), reviewed as diffs, and drafted by an LLM against the published schema and component contracts.