Mosaic one model, many lenses

ADR 0039 — Declared dashboards + cards/kanban list views

  • Status: accepted
  • Date: 2026-10-05
  • Slice: P39 (builder-roadmap: DSL-first, codegen-first, UI-first)

Context

P29 added a single built-in /dashboard (static entity counts + live event mix) — a fixed shape that says nothing about the app's own data. Real operational views are per-app compositions of KPI numbers, charts, and fresh-rows tables over projections, and different teams want different compositions (an ops overview vs. a billing view). Meanwhile the list pages generated for projections were always one shape — a table — but many read models are naturally better shown as cards (an order, a rule set) or as a board grouped by a status-like field (a kanban column per hit_policy). Both needs were previously impossible to express in the DSL.

Decision

1. Top-level dashboards: (DSL → plan)

app gains an optional top-level dashboards list:

dashboards:
  - name: ops
    about: "Operational overview"
    cards:
      - kind: kpi        # label + projection → row count
        label: orders
        projection: Orders
      - kind: chart      # label + projection + chart: bar|line|area|donut + x + y
        label: rules by hit policy
        projection: RuleSetList
        chart: donut
        x: hit_policy
        y: rules_count
      - kind: list       # label + projection, optional limit (default 5)
        label: recent orders
        projection: Orders
        limit: 5
      - kind: link       # label + href (+ optional icon)
        label: orders
        href: /projections/orders
        icon: database

The plan layer resolves each card against the declared projections in a post-pass (resolve_dashboards, after projections/pages are settled) and fails closed: duplicate-dashboard, dashboard-unknown-projection, dashboard-chart-kind, dashboard-chart-field (x/y must be projection fields), dashboard-link-href, dashboard-card-kind. Projection cards carry the projection's rows_fn (the generated store accessor), so codegen never re-derives names. list cards render the projection's declared list_page columns (up to 4, key field first; projection fields as fallback), limit rows, and a "view all →" link to the projection page.

2. pages::DashboardPage_{Pascal} (web codegen)

Each dashboard gets a route at /dashboards/{kebab} (the route is emitted in main, the component in the pages module, plus a nav entry under a Dashboards group). The page layout:

  • KPI/link grid (.dash-grid): KPI tiles show the live row count of their projection (SSR: the in-crate store accessor; CSR: a one-shot fetch of GET /api/projections/{kebab} into a drows_* signal — one fetch per distinct projection, deduped across cards); link tiles are anchors with a scene icon, target=_blank for external http(s) hrefs.
  • List cards: full-width Cards with a For over the (limit-trimmed) rows — the trim lives in a named closure emitted before the view! (a take(n).collect::<Vec<_>>() generic cannot sit inside a view! attribute), one column per declared list_page column, fmt_cell formatting, and a header link to the projection page.
  • Chart cards: reuse the projection-chart machinery (chart_data_stmt
    • chart_card) with the card's label as the chart title; the any_charts gate (which emits ChartDatum/CHART_COLORS) now also counts dashboard chart cards.

3. ui.list_page.view: table | cards | kanban (projection list pages)

Projection ui.list_page gains an optional view (default table) and, for kanban, a required view_group_by field:

  • cards — the rows render as a .cards-grid of .proj-card tiles: the key field (linked to the detail page when detail_page is on), up to 4 non-key value rows, and the row-state badge when row_states exist.
  • kanban — the rows are grouped into .kanban-col columns by view_group_by (a BTreeMap built in a named groups closure before the view!), each column headed by the group value + row count.
  • Fail-closed at plan time: unknown view, kanban without a view_group_by, a view_group_by that is not a projection field, and cards/kanban combined with template: split (master-detail assumes the table). template: tabs composes freely.

Consequences

  • Dashboards are declared, testable data — the plan layer is the single source of truth for what a card shows, and every bad reference is a build-time diagnostic, not a runtime blank tile.
  • One page per declared dashboard, no new server endpoints: everything a card shows is already served by the existing projection store + REST (GET /api/projections/{kebab}), so SSR and CSR stay in sync by construction.
  • The list-page shape is now a per-projection choice; the table remains the default, so no existing app changes shape unless it opts in.
  • The view! constraints that shaped the codegen (no <> generics in attribute values, closures before the macro, per-closure owned clones) are the same lessons the P36–P38 web slices learned and are now encoded in the generator rather than re-learned per app.

Verification

  • Plan tests: cards resolve against projections (rows_fn + list columns
    • link icon), fail-closed card diagnostics, duplicate dashboard names, kanban validation (group field required / must be a field / split conflict, valid case).
  • Render tests: DashboardPage_Ops + route + nav for a declared dashboard, absent without one; the cards and kanban list views render their respective markup.
  • Full-stack: new Orders projection over Order (upsert on OrderPlaced, status updates on confirm/cancel) with a cards list view; RuleSetList now renders as a kanban grouped by hit_policy; a declared ops dashboard (2 KPIs + donut + list + 2 links). All e2e scenarios green, incl. seven new full_stack_web scenarios asserting the SSR markup (KPI tiles with the seeded order, list row, link tiles, card grid, kanban column heads).
  • Conformance re-pinned: full-stack only — new orders projection (agg/server/app surface + projection page + chat fact), dashboard page
    • nav + routes, kanban/cards markup, dashboard CSS.