Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/changed-issue-4133.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Dashboard grid layouts now store a single reading/packing sequence (`order`) instead of a row coordinate — drag, resize and mobile reorder all run in one coordinate space, and migration 269 converts saved layouts in place
8 changes: 5 additions & 3 deletions client/src/components/dashboard/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

Dashboard widgets are registered in `widgetRegistry.jsx` — each entry has `{ id, label, Component, width, defaultH?, gate? }`. The Dashboard page renders the active layout's widget list from this registry; named layouts persist in `data/dashboard-layouts.json` and are managed via `GET/PUT/DELETE /api/dashboard/layouts`. Built-in layouts (`default`, `focus`, `morning-review`, `ops`) are seeded on first read and cannot be deleted.

**Grid positions:** layouts also carry a `grid: [{ id, x, y, w, h, fixedH? }]` array — free-form positions on a 12-column grid (rows ~80px each). When `grid` is empty (legacy/unmigrated layouts) the renderer auto-flows widgets using `synthesizeGrid` based on each widget's `width` keyword and `defaultH`. The "Arrange" button on the Dashboard enters edit mode where every widget exposes a move (top-right) and resize (bottom-right) handle; drag is snap-to-grid with collision-resolve via `placeAndCompact` (pins the moved item, slots others into the smallest non-colliding y). Save persists to the active layout's `grid`. The grid renderer collapses to a single-column stack below 640px container width, rendered in reading order (`y`, then `x`) — never grid-array order, which `placeAndCompact` deliberately scrambles by hoisting the moved item to the front.
**Grid positions:** layouts also carry a `grid: [{ id, x, w, order, h?, fixedH? }]` array — column placement on a 12-column grid plus a reading/packing sequence. When `grid` is empty (legacy/unmigrated layouts) the renderer auto-flows widgets using `synthesizeGrid` based on each widget's `width` keyword and `defaultH`. The "Arrange" button on the Dashboard enters edit mode where every widget exposes a move (top-right) and resize (bottom-right) handle; a move snaps horizontally to columns and re-inserts the cell into the sequence at the rank its dropped pixel position implies (`rankAtPixel` → `insertAtOrder`), renumbering `order` densely. Save persists to the active layout's `grid`. The grid renderer collapses to a single-column stack below 640px container width, rendered in reading order (`order`, then `x` as a tiebreak) — never grid-array order, which nothing keeps sorted.

**Height is measured, not declared.** `h` is NOT the rendered height. A cell measures its widget's natural height with a `ResizeObserver` and `packVertically` positions everything in pixels — each cell floats up to just below the nearest already-placed cell that shares a column. That's what stops an 80px card from reserving a 400px slot and leaving a band of dead whitespace across the row. `h` survives as the pre-measurement fallback (first paint) and as what an older client — which knows nothing about `fixedH` — reads out of a saved layout, so every drag commit refreshes it from the measurement via `toPackSpace`. A cell whose height the user actually dragged carries `fixedH: true` and keeps `h` exactly, clipping content the way the whole grid used to; the ⌃⌄ handle (edit mode, pinned cells only) hands the height back to the content. Stored `y` no longer sets the pixel offset — it only decides reading order, which is also the packing order, which is why the visual result still matches the arrangement the user saved. `defaultH` in the registry is likewise only a first-paint ballpark.
**One vertical coordinate (#4133).** There is no stored row position. Horizontal placement is declared (`x`/`w`); vertical placement belongs entirely to `packVertically`, which lays cells out in pixels, and `order` says only which cell packs first. That is why a gesture never has to reconcile "where the cell says it is" with "where it is drawn" — only one of those exists. Persisted layouts predating this carried `{ x, y, w, h }`; `sanitizeGridItem`/`sequenceGrid` in `server/services/dashboardLayouts.js` still derive `order` from a legacy `y` on read (shape-probed, not version-flagged), and `scripts/migrations/269-dashboard-grid-order.js` rewrites the file on disk. **A new widget-seeding migration must append `{ id, x, w, order, h }`, not `{ x, y, w, h }`** — the older seed migrations (029/030/033/070/145/156/191) still compute `y` because they only ever run on pre-269 files.

**Height is measured, not declared.** `h` is NOT the rendered height. A cell measures its widget's natural height with a `ResizeObserver` and `packVertically` positions everything in pixels — each cell floats up to just below the nearest already-placed cell that shares a column. That's what stops an 80px card from reserving a 400px slot and leaving a band of dead whitespace across the row. `h` survives as the pre-measurement fallback (first paint) and as what an older client — which knows nothing about `fixedH` — reads out of a saved layout, so every drag commit refreshes it from the measurement via `withMeasuredHeights`. A cell whose height the user actually dragged carries `fixedH: true` and keeps `h` exactly, clipping content the way the whole grid used to; the ⌃⌄ handle (edit mode, pinned cells only) hands the height back to the content. `defaultH` in the registry is likewise only a first-paint ballpark.

**Mobile editing.** Free-form move/resize is desktop-only (a phone has no room for positional editing), but *reordering* works on mobile: edit mode swaps the two grid handles for a single reorder handle. The 1-D sort goes through **`@dnd-kit/sortable`** — the same `PointerSensor` + `KeyboardSensor` + `verticalListSortingStrategy` pairing as `cos/tabs/TasksTab.jsx` and `universeBuilder/InfluenceChipsInput.jsx` — which is what supplies touch, keyboard (space to lift, arrows to move), multi-pointer safety, screen-reader announcements and edge auto-scroll. **Do not hand-roll a pointer gesture for this**; the free-form 2-D drag stays hand-rolled only because arbitrary grid placement is not what a sortable list does. Use `dndTransformToCss` from `client/src/lib/dndTransform.js` in the style object, never `@dnd-kit/utilities`.

On drop, `onDragEnd` re-flows the whole grid through `reflowToOrder` so its reading order matches the new stack order, preserving each widget's `w`/`h`. That means a mobile reorder re-packs the desktop layout into row-flow — the only coherent way to express a one-dimensional edit as 2D coordinates, and the edit-mode hint says so. `synthesizeGrid` is built on the same helper. The handle keeps `touch-action: none`; without it the browser claims the pointer stream for scrolling and the gesture never starts.
On drop, `onDragEnd` re-flows the whole grid through `reflowToOrder` so its reading order matches the new stack order, preserving each widget's `w`/`h`. That means a mobile reorder re-packs the desktop layout into row-flow — the only coherent way to express a one-dimensional edit as column placement, and the edit-mode hint says so. `synthesizeGrid` is built on the same helper. The handle keeps `touch-action: none`; without it the browser claims the pointer stream for scrolling and the gesture never starts.

**One source of truth for the mobile/desktop seam.** `DashboardGrid` measures its own *container* (`useContainerWidth`), which page padding makes narrower than the viewport — so a Tailwind `sm:` breakpoint disagrees with it in a ~30px band. Anything outside the grid that needs to know which affordance is live (the Dashboard's edit-mode hint) reads it from the `onLayoutModeChange` callback, never from a CSS breakpoint.

Expand Down
Loading