ADR-0103: Dashboard widget catalog — one registry of dashboard-able widgets, per-surface manifests
- Status: DRAFT (pending Gera review)
- Date: 2026-07-18
- Deciders: Gera, dashboard-v6 session
- Related: ADR-0097 (canvas S/M/L grammar), the drill-down interaction
audit (
docs/planning/drilldown-interaction-audit.md), PR #4095 (inline metric panels / InspectorDock deletion), PR #4106 (expand-in-place pilot), plan docdocs/planning/dashboard-catalog-plan.md.
Context
The main dashboard has a widget canvas (ADR-0097): a static WidgetDef list
in canvas/widgets.ts (10 ids, S/M/L footprints, defaults), a pure layout
engine, per-user server-persisted layouts, and — as of the pilot — an
expand-in-place drill gesture where clicking a metric card grows the card
itself into its full detail view.
Meanwhile, five other pages are dashboards in everything but machinery: the
/leasing hub, /leasing/prospects, /maintenance hub, /maintenance/costs,
/maintenance/turnovers each render metric-card rows that drill via
hand-slotted InlineInsightPanels, and property detail renders six metric
cards that don't drill at all. Each page hand-wires its own cards, its own
inspector instance, its own panel slot. There is no shared notion of "the
set of widgets a dashboard-like page can host."
Gera's direction: the expand-in-place gesture should work for every component on any dashboard-like page, and every such widget should live in one dashboard catalog — so that, down the line, users can add/remove widgets per page like an iPhone homescreen (a "+" button in the hold-to-edit bar). For now the requirement is: the catalog exists, is exhaustive, and the gesture rolls out everywhere it fits.
Decision
1. A catalog/ module owns widget definitions; surfaces own instances
New module src/components/feature/dashboard/catalog/:
// catalog.ts (shape sketch)
export type CatalogWidgetId =
| 'occupancy' | 'rents' | 'noi' | 'leasing' | 'renewals' | 'leadSource'
| 'weekly' | 'maintenance' | 'turnovers' | 'metricTable'
| 'prospects.vacantUnits' | 'prospects.activeProspects' | /* … */
| 'costs.vendorSpend' | 'turnovers.avgTurnTime'
| 'property.occupancy' | 'property.revenue' | /* … */;
export type WidgetFamily = 'metric' | 'pipeline' | 'list' | 'counter-strip' | 'table' | 'savings';
export type ExpandMode = 'in-place' | 'in-place-multi' | 'none';
export interface CatalogEntry {
id: CatalogWidgetId;
label: string; // plain-English, PM-facing
family: WidgetFamily;
sizes: WidgetSize[]; // S/M/L grammar (ADR-0097 semantics)
expand: ExpandMode;
/** Insight source for expand content, when expand !== 'none'. */
insight?: InspectorContentRef;
surfaces: SurfaceId[]; // where this widget may appear
}
// surfaces.ts (shape sketch)
export type SurfaceId =
| 'dashboard' | 'leasingHub' | 'prospects' | 'maintenanceHub'
| 'costs' | 'turnovers' | 'propertyDetail';
export interface SurfaceManifest {
id: SurfaceId;
widgets: CatalogWidgetId[]; // allowlist
defaults?: Record<CatalogWidgetId, Omit<WidgetRect, 'id'>>; // canvas surfaces only
editing: 'canvas' | 'static'; // hold-to-edit only where 'canvas'
}
The catalog is pure data — renderers stay host-supplied via the existing
nodes render-prop map, exactly like canvas/widgets.ts today. This keeps
the module import-cycle-free and unit-testable, and lets each page keep
ownership of its data fetching.
canvas/widgets.ts's WidgetDef list folds into the catalog (the dashboard
becomes surface 'dashboard' with editing:'canvas'); canvas/ keeps the
pure layout engine (layout.ts) and the DashboardCanvas host.
2. One gesture everywhere — expand mode only varies in how targets are hosted
Gera's directive (2026-07-18): "every card — rents, NOI, the bar charts,
everything — should have the animated expanded version." The animated
expand-in-place gesture is THE drill interaction on every dashboard-like
surface; InlineInsightPanel retires entirely once its last consumer
converts (one source of truth — no permanent panel/expand split).
in-place— single-metric and pipeline cards (the pilot's gesture): the card expands into its one insight spec.in-place-multi— multi-target cards (the weekly counter strip, the metric summary table) where each counter/row drills to a different spec: the card expands with the clicked target ACTIVE inside it — the expanded card hosts the per-target insight, and the sibling counters/rows render as a switcher strip in the expanded header. One card, N targets, same gesture.none— widgets with no drillable snapshot YET (Active Vendors, /clara savings tiles) — rendered, catalogued, not clickable. A temporary state, not a design position.
The renewals board's month cards stay OUT of the catalog: they are a filter control (the audit's sanctioned DATA→filter pattern), not metric widgets.
3. Static pages get CardExpandGroup, not a canvas
Non-dashboard surfaces keep their static grids. A new light host,
CardExpandGroup, provides the expand-in-place gesture (same
ExpandedWidgetCard chrome, Esc/click-outside collapse, one-detail-at-a-time
invariant) over a plain CSS grid — no drag, no resize, no layout
persistence. Hold-to-edit remains dashboard-only (audit §1; Gera 2026-07-18).
4. Drift-guarded exhaustiveness
A drift test (mirroring the metric-catalog pattern) pins:
- every
SurfaceManifest.widgetsid exists in the catalog; - every
expand:'in-place'entry carries aninsightref that resolves to a real spec-builder source; - every surface that hosts a catalog id supplies a renderer for it;
editing:'canvas'only on'dashboard'.
Consequences
- One place to answer "what widgets exist and where may they appear" — the
precondition for the future "+" add/remove affordance (per-user widget
sets are a natural extension of the existing server-layout persistence
and
mergeLayout's known/unknown-id handling). InlineInsightPanelretires surface-by-surface as conversions land and is deleted entirely when the last consumer converts (Gera 2026-07-18: everything gets the animated expand; no permanent panel/expand split).- Property detail's dead-end cards (audit P4) get their drills by joining the catalog: three new insight-spec branches (openWorkOrders, delinquencyRate, mtdVendorSpend) — the other three reuse existing specs.
- New dashboard-able widgets must register in the catalog or fail the drift test — no more page-local one-off metric cards.
Alternatives considered
- Extend
canvas/widgets.tsin place — rejected: it is canvas-specific (defaults coupled to the dashboard's 12-col layout) and would tangle the pure layout engine with cross-surface concerns. - Full canvas editing on every surface — rejected for now: hold-to-edit
is deliberately the dashboard's affordance; section pages are purpose-built
tools. The manifests leave the door open (
editingflag) without paying the cost today. - Runtime
registerWidget()registration — rejected: static TypeScript-enforced data matches the repo's catalog patterns (metric-catalog, tools-platform) and gives compile-time exhaustiveness; runtime registration gives none of that and invites import cycles.