ADR-0097 — The dashboard is a widget canvas
Status: Accepted (2026-07-17)
Owner decision: Gera — "make sure we have almost like a canvas experience";
locked interaction answers 2026-07-17: fill law = vertical compaction, edit
mode = hold-to-enter, shipped folded into the inline-insight-panel PR.
Plan: docs/planning/dashboard-inline-insight-canvas-plan.md (Phase 3).
Decision
The dashboard's card stack becomes a 12-column widget canvas:
- Every card is a widget with a rect
{x, y, w, h}in grid units ongrid-template-columns: repeat(12, 1fr)/grid-auto-rows: minmax(72px, auto). Spans are a MINIMUM — content taller than its footprint grows the row; nothing clips or scrolls inside a card (the same "data is just out" rule the inline panel follows). - Widgets declare allowed footprints (S/M/L grammar,
canvas/widgets.ts) — a widget can only take sizes its content works at. Freeform resize is deliberately NOT offered. - Hold-to-edit: press-and-hold ~400ms enters layout mode (card content goes pointer-inert; Done / Reset bar appears; Esc exits). Entering edit closes the inline panel so there is one grid to reason about.
- Move: grab → lift → drag; other widgets preview their compacted positions live; drop commits.
- Resize: corner-grab snaps between allowed footprints ("Figma's gesture, a grid's outcomes"); the size badge shows the snapped label and clicking it cycles sizes.
- Fill law — 2D gravity (Part B, 2026-07-17, supersedes the v1 vertical-only compaction): after every mutation, widgets settle in reading order, each taking the first fitting position scanning rows top-down then columns left-to-right. Position is emergent from ORDER + SIZES (the iOS model) — the user owns the order (drags reorder) and the sizes, never a raw column. Shrinking a widget lets the ones after it slide into the vacated space — the v1 law kept columns fixed, which trapped that space (Gera: "things get really trapped"; approved the trade 2026-07-17, choosing intuitive/low-maintenance over column ownership). A hole nothing fits stays open; a partial last row is allowed; the system still never RESIZES a widget the user didn't touch (auto-stretch remains rejected — it silently rewrites user choices).
- Persistence: per-user server-side (
User.dashboardLayout, managed via/api/settings/dashboard-layout— theignoredPropertyIdsper-user pattern), so the layout follows the login across devices and re-logins.localStorage(propflow:dashboard-canvas-layout:v1) remains as a first-paint cache the server response always overwrites; a browser-only layout from the localStorage era is adopted server-side on first load. Either source merges over registry defaults on mount (new widgets appear below, removed ids drop). Reset restores defaults and clears the server copy. - The inline insight panel (the click-a-card drill-down) renders as a full-width row injected directly below the anchor widget's grid row — the canvas splits into two stacked grids at that boundary.
Architecture
canvas/layout.ts— the PURE engine:compactLayout(the 2D fill law),moveWidget(reorder + reflow — the drop point picks a reading-order position; swaps/push-downs emerge from the flow),resizeWidget,snapToAllowedSize,mergeLayout. No React/DOM; fully unit-tested (__tests__/layout.test.ts).canvas/widgets.ts— the grammar: widget ids, allowed sizes, default layout (reproduces the pre-canvas page). Pure data — NODES are supplied byDashboardHome, which owns data + inspector wiring, so there is no registry→home import cycle.canvas/DashboardCanvas.tsx— the interactive renderer (CSS grid + framer-motionlayoutanimations + pointer-event drag/resize).
Constraints & deliberate limits
- Coarse pointers / <768px render the plain stacked reading order with no edit affordances — long-press fights scroll on touch. If phones need layout editing, add an explicit edit button; do not re-enable long-press.
- Keyboard layout editing (arrow-key move in edit mode) is not in v1 — tracked as a follow-up; view-only keyboard users are unaffected (cards remain ordinary click targets outside edit mode).
- Layout was per-BROWSER (localStorage) in v1; the cross-device follow-up
shipped as
User.dashboardLayout(field on the User row — Option A over a separateDASHBOARD_LAYOUTrow, since a ~10-widget rect map is a few hundred bytes and theignoredPropertyIdspattern fits exactly). The server copy carries aschemaVersion(and the localStorage cache key a:vNsuffix) so a breaking grammar change can retire stale layouts wholesale. - No dnd/grid dependency was added: locked spans + compaction made
hand-rolled quantized pointer math smaller than adapting
react-grid-layout(absolute positioning, own styling) or@dnd-kit(no resize primitive).
Rejected alternatives
- react-grid-layout — move+resize out of the box, but imposes its own absolute-position system + styles and freeform resize we'd have to fight.
- Section-level reorder first (Phase 3a in the plan) — Gera chose straight-to-per-card (D4), so the card registry lift happened now.
- Strict no-gaps-anywhere fill via auto-stretch — still rejected: the 2D fill law moves widgets into holes they fit but never RESIZES one to plug a hole; a remainder nothing fits stays open rather than silently rewriting a size the user chose.
- Vertical-only compaction (the v1 fill law, "user owns columns") — superseded by 2D gravity (Part B): fixed columns trapped the space a shrink vacated, and the surgical fill-only-on-shrink alternative was more code, more edge cases, and left messy states reachable.