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 ofGET /api/projections/{kebab}into adrows_*signal — one fetch per distinct projection, deduped across cards); link tiles are anchors with a scene icon,target=_blankfor externalhttp(s)hrefs. - List cards: full-width
Cards with aForover the (limit-trimmed) rows — the trim lives in a named closure emitted before theview!(atake(n).collect::<Vec<_>>()generic cannot sit inside aview!attribute), one column per declaredlist_pagecolumn,fmt_cellformatting, and a header link to the projection page. - Chart cards: reuse the projection-chart machinery (
chart_data_stmtchart_card) with the card'slabelas the chart title; theany_chartsgate (which emitsChartDatum/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-gridof.proj-cardtiles: the key field (linked to the detail page whendetail_pageis on), up to 4 non-key value rows, and the row-state badge whenrow_statesexist.kanban— the rows are grouped into.kanban-colcolumns byview_group_by(aBTreeMapbuilt in a namedgroupsclosure before theview!), each column headed by the group value + row count.- Fail-closed at plan time: unknown
view,kanbanwithout aview_group_by, aview_group_bythat is not a projection field, andcards/kanbancombined withtemplate: split(master-detail assumes the table).template: tabscomposes 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
Ordersprojection overOrder(upsert onOrderPlaced, status updates on confirm/cancel) with acardslist view;RuleSetListnow renders as a kanban grouped byhit_policy; a declaredopsdashboard (2 KPIs + donut + list + 2 links). All e2e scenarios green, incl. seven newfull_stack_webscenarios 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
ordersprojection (agg/server/app surface + projection page + chat fact), dashboard page- nav + routes, kanban/cards markup, dashboard CSS.