ADR-0103: Dashboard widget catalog — one registry of dashboard-able widgets, per-surface manifests

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).

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:

Consequences

Alternatives considered