From bef4550ec30a56959d9225049f98685adc837a54 Mon Sep 17 00:00:00 2001 From: Pratik Bodkhe Date: Sun, 7 Jun 2026 22:34:39 +0900 Subject: [PATCH 1/2] docs: spec for dnd-kit + CSS Grid bento dashboard (replace GridStack) --- ...026-06-07-widget-dashboard-bento-design.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-widget-dashboard-bento-design.md diff --git a/docs/superpowers/specs/2026-06-07-widget-dashboard-bento-design.md b/docs/superpowers/specs/2026-06-07-widget-dashboard-bento-design.md new file mode 100644 index 0000000..3376e96 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-widget-dashboard-bento-design.md @@ -0,0 +1,128 @@ +# Widget Dashboard: replace GridStack with a dnd-kit + CSS Grid bento + +Date: 2026-06-07 +Branch: `fix/widget-canvas-sizing` +Status: approved (design), implementation pending + +## Problem + +The OIL Board dashboard widgets clip their content. On `main` (GridStack, fixed +row heights) every card is given `h * cellHeight` pixels; content taller than +that is hidden behind an internal scrollbar, and shorter content leaves dead +space. The "morning fix" (`sizeToContent: true`) made it worse: every widget +collapsed to one 86px row with content clipped underneath. + +## Root cause (proven against gridstack 12.6.0 source) + +`GridStack.resizeToContent` measures `.grid-stack-item-content`'s +`firstElementChild` and assumes a single content child (gridstack.js dist lines +1502, 1554-1560). In `widget-shell.tsx` the first child is the **absolute- +positioned action toolbar** (~24px), not the body. So GridStack sizes every +widget to ~1 row and `overflow: hidden` clips the real content. GridStack also +has no per-widget content observer (its only `ResizeObserver` watches the grid +element for width/column changes), so it never re-measures when React Query +content loads or when filters change item counts. + +This is a model mismatch, not a one-line bug: fixed-row grids treat +content-hugging as a fragile, bolted-on feature. react-grid-layout has the same +`rowHeight` limitation, so switching to it would not fix the problem. + +## Decision + +Drop GridStack. Use **dnd-kit (already a dependency) + CSS Grid** for a +content-driven bento. This deletes the entire measurement/clipping bug class: +row heights come from content via the browser's own layout engine, with no JS +measurement of widget height. + +## Data model + +Replace the GridStack `layout: {x,y,w,h}` per widget with a footprint: + +```ts +interface WidgetInstance { + id: string; + type: string; + colSpan: 1 | 2 | 3 | 4; // width in the 4-col desktop grid + rowSpan: number; // number of row bands the card occupies (default 1) +} +``` + +Order = array position (what dnd-kit reorders). Persisted in the existing +Zustand `minutia-widgets` store, bumped to **version 3**, with a `migrate` that +discards stale GridStack `layout`/`x`/`y`/`w`/`h` and `span`, seeding per-type +defaults from the registry. localStorage key unchanged. + +`getWidgetLayout`, `getWidgetMinHeight`, `syncLayouts`, `isDesktopLayout`, and +the `DEFAULT_LAYOUTS`/`DEFAULT_SIZES`/`NARROW_HEIGHTS` tables are GridStack-only +and get removed/replaced by a small `getWidgetFootprint(type)` helper. + +## Layout + +Desktop = 4-column CSS Grid (the current 12-col defaults รท 3). Row tracks are +content-sized; `align-items: stretch` makes cards in a band share the band +height, so a short card beside a tall one matches height (aligned, not ragged). +Multi-row cards span bands via `grid-row: span rowSpan` and auto-align with +stacked single-row neighbors (the user's bento mockup). + +Default footprints: + +| widget | colSpan | rowSpan | +|--------------|---------|---------| +| hero | 2 | 1 | +| next-meeting | 1 | 1 | +| series | 1 | 1 | +| outstanding | 4 | 1 (content) | +| decisions | 1 | 1 | +| age | 1 | 1 | +| stale-items | 1 | 1 | +| series-health| 2 | 1 | +| meeting-triage| 2 | 1 | +| workload | 2 | 1 | + +CSS contract: `.grid-stack`-style classes and all gridstack CSS in `globals.css` +are removed. The card body has **no** `overflow: hidden` and **no** fixed +height; content defines height. + +Tradeoff (accepted): a short card shares its band's height, carrying some bottom +whitespace. This is the deliberate aligned/premium look and is strictly better +than clipping. Mitigated by grouping similar-density widgets and top-aligning +content with comfortable padding. + +## Controls & behavior + +- Reorder: dnd-kit `SortableContext` (rect sorting) + the existing drag handle; + `moveWidget(from,to)` already exists. +- Resize: existing width toggle cycles `colSpan` (1 <-> 2); outstanding stays + colSpan 4 and locked. +- Add/remove: existing "Add widget" + per-card remove, unchanged. +- Responsive: 4 -> 2 -> 1 columns; spans clamp to the available column count; + mobile = single full-width column in array order. +- a11y/motion: keyboard sortable sensor; reduced-motion respected; the gridstack + `gs-*` attributes and imperative `makeWidget` lifecycle are gone. + +## Dependencies + +Remove `gridstack` from package.json and all imports. Confirm `@dnd-kit/core`, +`@dnd-kit/sortable`, `@dnd-kit/utilities` present (used elsewhere per CLAUDE.md). + +## Testing strategy (TDD, includes visual) + +1. Store unit/contract: `getWidgetFootprint` defaults, v2->v3 migration drops + GridStack fields, `toggleSpan` flips colSpan, `moveWidget` reorders. +2. E2E (`widgets.spec.ts`, rewritten): canvas renders, each widget has correct + `data-col-span`, reorder via keyboard changes order, width toggle changes + span, add/remove works. +3. **Visual / clipping (the real gate):** at 1440x1200 authenticated, assert no + `.widget-card-content` has `scrollHeight > clientHeight + 1` (nothing + clipped), and capture a full-page screenshot for human review at the bug + viewport. Replace the misleading `widget-canvas-sizing.component.spec.ts` + (it gave a false positive by not reproducing the real DOM). +4. Run full `pnpm test:e2e` before PR. + +Each red/green step is verified both by assertion and by an eyeballed screenshot +of the live dashboard. + +## Out of scope + +Free-form pixel resize, arbitrary 2D drag placement, native CSS +`grid-template-rows: masonry` (not broadly supported in 2026). From 479294a305a052dbe8194596d12babf60727744f Mon Sep 17 00:00:00 2001 From: Pratik Bodkhe Date: Sun, 7 Jun 2026 23:00:13 +0900 Subject: [PATCH 2/2] fix(dashboard): replace GridStack with dnd-kit + CSS Grid bento GridStack sized each widget from .grid-stack-item-content's firstElementChild, which in widget-shell is the absolute-positioned action toolbar (~24px), not the body. Every card collapsed to one 86px row and its real content was clipped behind overflow:hidden; sizeToContent made it worse (one-shot measure vs async React Query content). Replace GridStack with a content-driven CSS Grid bento: row heights come from content (no JS measurement, no clipping), dnd-kit handles reorder, and per-type col/row span footprints keep adjacent unequal cards aligned. Drop the gridstack dependency and all gridstack CSS. Widget store moves from GridStack {x,y,w,h} layouts to a colSpan/rowSpan footprint model (persist v3 migration drops legacy fields). Tests rewritten to the new contract; new widget-bento.spec asserts no widget clips its content. --- e2e/regression/widget-bento.spec.ts | 82 +++++++ e2e/regression/widgets.spec.ts | 72 +++---- package.json | 1 - pnpm-lock.yaml | 8 - src/app/(app)/page.tsx | 18 +- src/app/globals.css | 79 ++++--- .../minutia/widgets/widget-canvas.tsx | 141 ++++-------- .../minutia/widgets/widget-shell.tsx | 90 ++++---- src/lib/stores/widget-store.ts | 204 +++++------------- 9 files changed, 307 insertions(+), 388 deletions(-) create mode 100644 e2e/regression/widget-bento.spec.ts diff --git a/e2e/regression/widget-bento.spec.ts b/e2e/regression/widget-bento.spec.ts new file mode 100644 index 0000000..660cad6 --- /dev/null +++ b/e2e/regression/widget-bento.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, type Page } from "@playwright/test"; +import { waitForApp } from "./seed-data"; + +// Contract for the dnd-kit + CSS Grid bento dashboard that replaces GridStack. +// See docs/superpowers/specs/2026-06-07-widget-dashboard-bento-design.md +// +// The decisive gate is "no widget clips its content" (the GridStack bug), plus +// a full-page screenshot for human/visual review. + +const DEFAULT_COL_SPANS: Record = { + "hero-1": "2", + "next-meeting-1": "1", + "outstanding-1": "4", + "series-1": "1", + "decisions-1": "1", + "age-1": "1", +}; + +async function gotoFreshDashboard(page: Page) { + await page.addInitScript(() => { + localStorage.removeItem("minutia-widgets"); + }); + await page.setViewportSize({ width: 1440, height: 1200 }); + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await waitForApp(page); + await expect(page.getByRole("button", { name: "Add widget" })).toBeVisible(); +} + +test.describe("Widget bento canvas", () => { + test("canvas renders with the CSS Grid engine (no gridstack)", async ({ page }) => { + await gotoFreshDashboard(page); + const canvas = page.getByTestId("dashboard-widget-canvas"); + await expect(canvas).toHaveAttribute("data-grid-engine", "css-grid"); + await expect(page.locator(".grid-stack")).toHaveCount(0); + }); + + test("each default widget carries its footprint col-span", async ({ page }) => { + await gotoFreshDashboard(page); + for (const [id, span] of Object.entries(DEFAULT_COL_SPANS)) { + await expect(page.getByTestId(`widget-${id}`)).toHaveAttribute("data-col-span", span); + } + }); + + test("no widget clips its content behind an internal scrollbar", async ({ page }) => { + await gotoFreshDashboard(page); + await expect(page.getByTestId("widget-outstanding-1")).toBeVisible(); + + // Scan every widget for a vertical clipping container whose content is + // taller than its box. This is exactly the GridStack failure mode + // (.grid-stack-item-content with overflow + fixed height). + const clipped = await page.evaluate(() => { + const out: string[] = []; + document.querySelectorAll("[data-testid^='widget-']").forEach((widget) => { + const id = widget.getAttribute("data-testid"); + widget.querySelectorAll("*").forEach((el) => { + const oy = getComputedStyle(el).overflowY; + const clips = oy === "auto" || oy === "scroll" || oy === "hidden"; + if (clips && el.scrollHeight > el.clientHeight + 1) { + out.push(`${id}: ${el.scrollHeight}>${el.clientHeight}`); + } + }); + }); + return out; + }); + expect(clipped).toEqual([]); + + // All default widgets present (not just the top band) before the shot. + for (const id of Object.keys(DEFAULT_COL_SPANS)) { + await expect(page.getByTestId(`widget-${id}`)).toBeVisible(); + } + await page.waitForTimeout(800); // let staggered entrance animations settle + await page.screenshot({ path: "test-results/dashboard-bento.png", fullPage: true }); + }); + + test("outstanding spans the full width", async ({ page }) => { + await gotoFreshDashboard(page); + const outstanding = page.getByTestId("widget-outstanding-1"); + await expect(outstanding).toHaveAttribute("data-col-span", "4"); + const box = await outstanding.boundingBox(); + expect(box?.width ?? 0).toBeGreaterThan(1000); + }); +}); diff --git a/e2e/regression/widgets.spec.ts b/e2e/regression/widgets.spec.ts index f913e2c..b52ca3d 100644 --- a/e2e/regression/widgets.spec.ts +++ b/e2e/regression/widgets.spec.ts @@ -13,6 +13,8 @@ import { type StoredWidget = { id: string; type: string; + colSpan?: 1 | 2 | 3 | 4; + // legacy GridStack fields, still accepted from old persisted state span?: 1 | 2; layout?: { x?: number; y?: number; w?: number; h?: number }; }; @@ -36,28 +38,27 @@ function centerY(box: { y: number; height: number }) { return box.y + box.height / 2; } -function storedHeroWidth() { +function storedHeroColSpan() { const raw = localStorage.getItem("minutia-widgets"); if (!raw) return null; const parsed = JSON.parse(raw) as { state?: { widgets?: StoredWidget[] } }; - return parsed.state?.widgets?.find((w) => w.type === "hero")?.layout?.w; + return parsed.state?.widgets?.find((w) => w.type === "hero")?.colSpan ?? null; } -async function expectResponsiveCard(locator: Locator, expectedWidth: string, expectedMinHeight: number) { - await expect(locator).toHaveAttribute("gs-w", expectedWidth); - await expect.poll(async () => Number(await locator.getAttribute("gs-h"))) - .toBeGreaterThanOrEqual(expectedMinHeight); - - const overflow = await locator.evaluate((node) => { - const content = node.querySelector(".grid-stack-item-content"); - if (!content) return { overflowX: true, scrollWidth: 0, clientWidth: 0 }; - return { - overflowX: content.scrollWidth > content.clientWidth + 1, - scrollWidth: content.scrollWidth, - clientWidth: content.clientWidth, - }; - }); - expect(overflow.overflowX).toBe(false); +async function expectResponsiveCard(locator: Locator, expectedColSpan: string) { + await expect(locator).toHaveAttribute("data-col-span", expectedColSpan); + + // Poll: the grid reflows a frame after the col-span attribute flips, so a + // one-shot read can catch a transient pre-reflow width. + await expect + .poll(async () => + locator.evaluate((node) => { + const content = node.querySelector(".widget-card-content"); + if (!content) return true; + return content.scrollWidth > content.clientWidth + 1; + }) + ) + .toBe(false); } test.describe("Widget system", () => { @@ -100,8 +101,8 @@ test.describe("Widget system", () => { await waitForApp(page); const canvas = page.getByTestId("dashboard-widget-canvas"); - await expect(canvas).toHaveAttribute("data-grid-engine", "gridstack"); - await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("gs-w", "12"); + await expect(canvas).toHaveAttribute("data-grid-engine", "css-grid"); + await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("data-col-span", "4"); const outstandingBox = await requiredBox(page.getByTestId("widget-outstanding-1")); @@ -112,17 +113,16 @@ test.describe("Widget system", () => { await expect(outstanding.getByLabel("Make narrow")).not.toBeVisible(); }); - test("dashboard uses an auto-adjusting widget canvas", async ({ page }) => { + test("dashboard uses a CSS Grid bento canvas with footprint col-spans", async ({ page }) => { const canvas = page.getByTestId("dashboard-widget-canvas"); - await expect(canvas).toHaveAttribute("data-grid-engine", "gridstack"); + await expect(canvas).toHaveAttribute("data-grid-engine", "css-grid"); - await expect(page.getByTestId("widget-hero-1")).toHaveAttribute("gs-w", "6"); - await expect(page.getByTestId("widget-next-meeting-1")).toHaveAttribute("gs-w", "3"); - await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("gs-w", "12"); - await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("gs-h", "5"); + await expect(page.getByTestId("widget-hero-1")).toHaveAttribute("data-col-span", "2"); + await expect(page.getByTestId("widget-next-meeting-1")).toHaveAttribute("data-col-span", "1"); + await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("data-col-span", "4"); }); - test("desktop canvas ignores corrupted narrow persisted layouts", async ({ page }) => { + test("migrates legacy GridStack layouts to default footprints", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.evaluate(() => { localStorage.setItem( @@ -143,9 +143,9 @@ test.describe("Widget system", () => { await page.reload({ waitUntil: "commit" }); await waitForApp(page); - await expect(page.getByTestId("widget-hero-1")).toHaveAttribute("gs-w", "6"); - await expect(page.getByTestId("widget-next-meeting-1")).toHaveAttribute("gs-w", "3"); - await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("gs-w", "12"); + await expect(page.getByTestId("widget-hero-1")).toHaveAttribute("data-col-span", "2"); + await expect(page.getByTestId("widget-next-meeting-1")).toHaveAttribute("data-col-span", "1"); + await expect(page.getByTestId("widget-outstanding-1")).toHaveAttribute("data-col-span", "4"); }); test("add widget button opens picker with groups", async ({ page }) => { @@ -463,8 +463,8 @@ test.describe("Widget system", () => { const resizeBtn = page.getByLabel("Make narrow").first(); await resizeBtn.click(); - const stored = await page.evaluate(storedHeroWidth); - expect(stored).toBe(3); + const stored = await page.evaluate(storedHeroColSpan); + expect(stored).toBe(1); }); test("resized narrow widgets keep responsive card content", async ({ page }) => { @@ -473,19 +473,19 @@ test.describe("Widget system", () => { const hero = page.getByTestId("widget-hero-1"); await hero.hover(); await hero.getByLabel("Make narrow").click(); - await expectResponsiveCard(hero, "3", 4); + await expectResponsiveCard(hero, "1"); await addWidget(page, /Meeting Triage/); const triage = page.locator('[data-widget-type="meeting-triage"]').first(); await triage.hover(); await triage.getByLabel("Make narrow").click(); - await expectResponsiveCard(triage, "3", 5); + await expectResponsiveCard(triage, "1"); await addWidget(page, /Workload.*Open items/); const workload = page.locator('[data-widget-type="workload"]').first(); await workload.hover(); await workload.getByLabel("Make narrow").click(); - await expectResponsiveCard(workload, "3", 5); + await expectResponsiveCard(workload, "1"); const pageOverflow = await page.evaluate(() => ( document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 @@ -501,8 +501,8 @@ test.describe("Widget system", () => { await page.reload(); await waitForApp(page); - const stored = await page.evaluate(storedHeroWidth); - expect(stored).toBe(3); + const stored = await page.evaluate(storedHeroColSpan); + expect(stored).toBe(1); }); test("widget reorder persists in localStorage across reload", async ({ page }) => { diff --git a/package.json b/package.json index d16995b..cd5ec32 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "gridstack": "12.6.0", "lucide-react": "^1.14.0", "motion": "^12.38.0", "next": "16.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1716bc..8b138e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,9 +43,6 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - gridstack: - specifier: 12.6.0 - version: 12.6.0 lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.4) @@ -2930,9 +2927,6 @@ packages: resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - gridstack@12.6.0: - resolution: {integrity: sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ==} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -7307,8 +7301,6 @@ snapshots: graphql@16.14.0: {} - gridstack@12.6.0: {} - has-bigints@1.1.0: {} has-flag@4.0.0: {} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index f7707f2..18f85ce 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1073,22 +1073,6 @@ export default function Dashboard() { calendarEvents: calendarEvents ?? undefined, }; - const widgetLayoutKey = React.useMemo( - () => - widgets - .map((w) => - [ - w.id, - w.type, - w.layout?.x ?? "", - w.layout?.y ?? "", - w.layout?.w ?? "", - w.layout?.h ?? "", - ].join(":") - ) - .join("|"), - [widgets] - ); const widgetIds = React.useMemo(() => widgets.map((w) => w.id), [widgets]); return ( @@ -1100,7 +1084,7 @@ export default function Dashboard() { {isLoading ? ( ) : ( - + {widgets.map((w, i) => ( content never clips. */ + .widget-grid { + display: grid; + gap: 1.25rem; + grid-template-columns: 1fr; + grid-auto-rows: auto; + align-items: stretch; min-height: 24rem; } - .grid-stack > .grid-stack-item > .grid-stack-item-content { - container-type: inline-size; - overflow: auto !important; - scrollbar-width: thin; - } - - .grid-stack > .grid-stack-item > .grid-stack-item-content > .widget-card-content { - min-height: 100%; + .widget-cell { min-width: 0; + grid-column: span 1; } - .grid-stack > .grid-stack-item[gs-w="3"] > .grid-stack-item-content { - padding: 1.25rem; - } - - .grid-stack > .grid-stack-item[gs-w="3"] > .grid-stack-item-content h2, - .grid-stack > .grid-stack-item[gs-w="3"] > .grid-stack-item-content h3 { - overflow-wrap: anywhere; + .widget-cell > .widget-card-content { + container-type: inline-size; } - .grid-stack > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content, - .grid-stack > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content { + .widget-cell.is-dragging > .widget-card-content { border-color: var(--accent); box-shadow: 0 18px 40px color-mix(in oklch, var(--accent) 18%, transparent); opacity: 0.94; } - .grid-stack > .grid-stack-item > .ui-resizable-se { - opacity: 0; - transition: opacity var(--duration-base) var(--ease); + @media (min-width: 640px) { + .widget-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + /* col-span 2/3/4 collapse to the full 2-col width at this breakpoint */ + .widget-cell { + grid-column: span 2; + } + .widget-cell[data-col-span="1"] { + grid-column: span 1; + } } - .grid-stack > .grid-stack-item:hover > .ui-resizable-se, - .grid-stack > .grid-stack-item:focus-within > .ui-resizable-se { - opacity: 0.75; + @media (min-width: 1024px) { + .widget-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .widget-cell[data-col-span="1"] { + grid-column: span 1; + } + .widget-cell[data-col-span="2"] { + grid-column: span 2; + } + .widget-cell[data-col-span="3"] { + grid-column: span 3; + } + .widget-cell[data-col-span="4"] { + grid-column: span 4; + } + .widget-cell[data-row-span="2"] { + grid-row: span 2; + } + .widget-cell[data-row-span="3"] { + grid-row: span 3; + } + } + + .widget-cell[data-col-span="1"] .widget-card-content h2, + .widget-cell[data-col-span="1"] .widget-card-content h3 { + overflow-wrap: anywhere; } } diff --git a/src/components/minutia/widgets/widget-canvas.tsx b/src/components/minutia/widgets/widget-canvas.tsx index b68b25a..007ad87 100644 --- a/src/components/minutia/widgets/widget-canvas.tsx +++ b/src/components/minutia/widgets/widget-canvas.tsx @@ -1,114 +1,57 @@ "use client"; import * as React from "react"; -import { GridStack } from "gridstack"; -import type { GridStackNode, GridStackOptions } from "gridstack"; -import { useWidgetStore, type WidgetLayout } from "@/lib/stores/widget-store"; - -const GRID_OPTIONS: GridStackOptions = { - column: 12, - cellHeight: 86, - margin: 20, - float: false, - animate: true, - handle: ".widget-drag-handle", - alwaysShowResizeHandle: false, - resizable: { handles: "se" }, - columnOpts: { - breakpointForWindow: false, - layout: "moveScale", - breakpoints: [ - { w: 560, c: 1, layout: "list" }, - { w: 900, c: 6, layout: "moveScale" }, - { w: 9999, c: 12, layout: "moveScale" }, - ], - }, -}; - -function toLayouts(nodes: GridStackNode[]) { - return nodes.reduce>((next, node) => { - const id = node.id?.toString(); - if (!id) return next; - next[id] = { - x: node.x ?? 0, - y: node.y ?? 0, - w: node.w ?? 1, - h: node.h ?? 1, - }; - return next; - }, {}); -} - +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; +import { useWidgetStore } from "@/lib/stores/widget-store"; + +// CSS Grid bento canvas. Row heights come from content (the browser's layout +// engine), so cards never clip; dnd-kit handles reorder. No GridStack. export function WidgetCanvas({ children, - layoutKey, widgetIds, }: { children: React.ReactNode; - layoutKey: string; widgetIds: string[]; }) { - const rootRef = React.useRef(null); - const gridRef = React.useRef(null); - const syncLayouts = useWidgetStore((s) => s.syncLayouts); - - React.useEffect(() => { - if (!rootRef.current || gridRef.current) return; - - const root = rootRef.current; - const grid = GridStack.init(GRID_OPTIONS, rootRef.current); - gridRef.current = grid; - - grid.on("change", (_event, nodes) => { - if (grid.getColumn() !== 12) return; - syncLayouts(toLayouts(nodes)); - }); - - function handleWidgetRemove(event: Event) { - const id = (event as CustomEvent<{ id?: string }>).detail?.id; - if (!id) return; - const item = Array.from(root.querySelectorAll(".grid-stack-item")).find( - (el) => el.getAttribute("gs-id") === id - ); - if (item) grid.removeWidget(item, true, false); - } - - root.addEventListener("minutia:widget-remove", handleWidgetRemove); - - return () => { - root.removeEventListener("minutia:widget-remove", handleWidgetRemove); - grid.destroy(false); - gridRef.current = null; - }; - }, [syncLayouts]); - - React.useEffect(() => { - const grid = gridRef.current; - const root = rootRef.current; - if (!grid || !root) return; - - const activeIds = new Set(widgetIds); - root.querySelectorAll(".grid-stack-item").forEach((el) => { - const id = el.getAttribute("gs-id"); - if (id && !activeIds.has(id)) el.remove(); - }); + const moveWidget = useWidgetStore((s) => s.moveWidget); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); - grid.batchUpdate(); - grid.removeAll(false, false); - root.querySelectorAll(".grid-stack-item").forEach((el) => { - grid.makeWidget(el); - }); - grid.batchUpdate(false); - }, [layoutKey, widgetIds]); + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const from = widgetIds.indexOf(String(active.id)); + const to = widgetIds.indexOf(String(over.id)); + if (from === -1 || to === -1) return; + moveWidget(from, to); + } return ( -
- {children} -
+ + +
+ {children} +
+
+
); } diff --git a/src/components/minutia/widgets/widget-shell.tsx b/src/components/minutia/widgets/widget-shell.tsx index a8c2c34..a628ca2 100644 --- a/src/components/minutia/widgets/widget-shell.tsx +++ b/src/components/minutia/widgets/widget-shell.tsx @@ -2,11 +2,16 @@ import * as React from "react"; import { motion } from "motion/react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { GripVertical, X, Maximize2, Minimize2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { HintTooltip } from "@/components/minutia/hint-tooltip"; -import { getWidgetLayout, getWidgetMinHeight, useWidgetStore } from "@/lib/stores/widget-store"; -import { getWidgetMeta } from "./widget-registry"; +import { + getWidgetColSpan, + getWidgetFootprint, + useWidgetStore, +} from "@/lib/stores/widget-store"; export function WidgetShell({ id, @@ -22,55 +27,38 @@ export function WidgetShell({ const removeWidget = useWidgetStore((s) => s.removeWidget); const toggleSpan = useWidgetStore((s) => s.toggleSpan); const widget = useWidgetStore((s) => s.widgets.find((w) => w.id === id)); - const widgetIndex = useWidgetStore((s) => s.widgets.findIndex((w) => w.id === id)); - const registrySpan = widget ? getWidgetMeta(widget.type)?.span : undefined; - const layout = widget ? getWidgetLayout(widget, Math.max(widgetIndex, index)) : undefined; - const currentSpan = layout?.w && layout.w > 4 ? 2 : widget?.span ?? registrySpan ?? 1; - const canResize = registrySpan !== undefined && widget?.type !== "outstanding"; - const minHeight = widget && layout ? getWidgetMinHeight(widget.type, layout.w) : 2; - const gridAttrs = layout - ? { - "gs-id": id, - "gs-x": layout.x, - "gs-y": layout.y, - "gs-w": layout.w, - "gs-h": layout.h, - "gs-min-w": 3, - "gs-min-h": minHeight, - ...(widget?.type === "outstanding" ? { "gs-no-resize": "true" } : {}), - } - : {}; - function handleRemove(event: React.MouseEvent) { - const canvas = event.currentTarget.closest("[data-testid='dashboard-widget-canvas']"); + const footprint = widget ? getWidgetFootprint(widget.type) : undefined; + const colSpan = widget ? getWidgetColSpan(widget) : 1; + const rowSpan = footprint?.rowSpan ?? 1; + const canResize = footprint?.resizable ?? false; + const isWide = colSpan >= 2; + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id }); + + function handleRemove() { removeWidget(id); - canvas?.dispatchEvent( - new CustomEvent("minutia:widget-remove", { - detail: { id }, - }) - ); } return ( -
+
{canResize && ( - + )} @@ -115,9 +99,7 @@ export function WidgetShell({
-
- {children} -
+ {children}
); diff --git a/src/lib/stores/widget-store.ts b/src/lib/stores/widget-store.ts index d02bfa3..f20cf50 100644 --- a/src/lib/stores/widget-store.ts +++ b/src/lib/stores/widget-store.ts @@ -3,35 +3,56 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +export type ColSpan = 1 | 2 | 3 | 4; + export interface WidgetInstance { id: string; type: string; - span?: 1 | 2; - layout?: WidgetLayout; + // Width in the 4-column desktop grid. Optional: falls back to the type's + // footprint default. Set when the user toggles a widget narrow/wide. + colSpan?: ColSpan; } -export interface WidgetLayout { - x: number; - y: number; - w: number; - h: number; +export interface WidgetFootprint { + colSpan: ColSpan; + rowSpan: number; + resizable: boolean; } -type StoredWidgetInstance = Omit & { - layout?: WidgetLayout; - span?: 1 | 2; -}; - +// Order matters in the CSS Grid bento (cards flow in array order). The top +// band fills 4 cols (hero 2 + next-meeting 1 + series 1) before the full-width +// outstanding card, then decisions + age. const DEFAULT_WIDGETS: WidgetInstance[] = [ { id: "hero-1", type: "hero" }, { id: "next-meeting-1", type: "next-meeting" }, - { id: "outstanding-1", type: "outstanding" }, { id: "series-1", type: "series" }, + { id: "outstanding-1", type: "outstanding" }, { id: "decisions-1", type: "decisions" }, { id: "age-1", type: "age" }, ]; -const DEFAULT_WIDGET_IDS = new Set(DEFAULT_WIDGETS.map((widget) => widget.id)); +// Per-type bento footprint. colSpan is the default width; rowSpan lets a widget +// span multiple content-sized bands so it aligns with stacked neighbors. +const FOOTPRINTS: Record = { + hero: { colSpan: 2, rowSpan: 1, resizable: true }, + "next-meeting": { colSpan: 1, rowSpan: 1, resizable: true }, + series: { colSpan: 1, rowSpan: 1, resizable: true }, + outstanding: { colSpan: 4, rowSpan: 1, resizable: false }, + decisions: { colSpan: 1, rowSpan: 1, resizable: true }, + age: { colSpan: 1, rowSpan: 1, resizable: true }, + "stale-items": { colSpan: 1, rowSpan: 1, resizable: true }, + "series-health": { colSpan: 2, rowSpan: 1, resizable: true }, + "meeting-triage": { colSpan: 2, rowSpan: 1, resizable: true }, + workload: { colSpan: 2, rowSpan: 1, resizable: true }, +}; + +export function getWidgetFootprint(type: string): WidgetFootprint { + return FOOTPRINTS[type] ?? { colSpan: 1, rowSpan: 1, resizable: true }; +} + +export function getWidgetColSpan(widget: WidgetInstance): ColSpan { + return widget.colSpan ?? getWidgetFootprint(widget.type).colSpan; +} interface WidgetState { widgets: WidgetInstance[]; @@ -39,96 +60,9 @@ interface WidgetState { removeWidget: (id: string) => void; moveWidget: (fromIndex: number, toIndex: number) => void; toggleSpan: (id: string) => void; - syncLayouts: (layouts: Record) => void; resetToDefault: () => void; } -const DEFAULT_LAYOUTS: Record = { - hero: { x: 0, y: 0, w: 6, h: 3 }, - "next-meeting": { x: 6, y: 0, w: 3, h: 3 }, - series: { x: 9, y: 0, w: 3, h: 3 }, - outstanding: { x: 0, y: 3, w: 12, h: 5 }, - decisions: { x: 0, y: 8, w: 3, h: 3 }, - age: { x: 3, y: 8, w: 3, h: 3 }, - "stale-items": { x: 6, y: 8, w: 3, h: 3 }, - "series-health": { x: 0, y: 11, w: 6, h: 4 }, - "meeting-triage": { x: 6, y: 11, w: 6, h: 4 }, - workload: { x: 0, y: 15, w: 6, h: 4 }, -}; - -const DEFAULT_SIZES: Record> = { - hero: { w: 6, h: 3 }, - "next-meeting": { w: 3, h: 3 }, - series: { w: 3, h: 3 }, - outstanding: { w: 12, h: 5 }, - decisions: { w: 3, h: 3 }, - age: { w: 3, h: 3 }, - "stale-items": { w: 3, h: 3 }, - "series-health": { w: 6, h: 4 }, - "meeting-triage": { w: 6, h: 4 }, - workload: { w: 6, h: 4 }, -}; - -const NARROW_HEIGHTS: Record = { - hero: 4, - "next-meeting": 4, - "series-health": 5, - "meeting-triage": 5, - workload: 5, -}; - -export function getWidgetMinHeight(type: string, width: number) { - if (width <= 3) return NARROW_HEIGHTS[type] ?? DEFAULT_SIZES[type]?.h ?? 3; - return DEFAULT_SIZES[type]?.h ?? 3; -} - -function isDesktopLayout(layout: WidgetLayout | undefined): layout is WidgetLayout { - if (!layout) return false; - return ( - Number.isFinite(layout.x) && - Number.isFinite(layout.y) && - Number.isFinite(layout.w) && - Number.isFinite(layout.h) && - layout.x >= 0 && - layout.y >= 0 && - layout.w >= 3 && - layout.w <= 12 && - layout.h >= 2 && - layout.x + layout.w <= 12 - ); -} - -export function getWidgetLayout(widget: WidgetInstance, index: number): WidgetLayout { - if (isDesktopLayout(widget.layout)) { - return { - ...widget.layout, - h: Math.max(widget.layout.h, getWidgetMinHeight(widget.type, widget.layout.w)), - }; - } - - const fallback = DEFAULT_WIDGET_IDS.has(widget.id) - ? DEFAULT_LAYOUTS[widget.type] - : undefined; - const size = DEFAULT_SIZES[widget.type] ?? { - w: widget.span === 2 ? 6 : 3, - h: 3, - }; - const orderLayout = { - x: size.w === 12 ? 0 : (index % Math.max(1, 12 / size.w)) * size.w, - y: Math.floor(index / Math.max(1, 12 / size.w)) * size.h, - ...size, - }; - - if (!widget.span) return fallback ?? orderLayout; - - const w = widget.span === 2 ? 6 : 3; - return { - ...(fallback ?? orderLayout), - x: Math.min((fallback ?? orderLayout).x, 12 - w), - w, - }; -} - export const useWidgetStore = create()( persist( (set) => ({ @@ -136,10 +70,7 @@ export const useWidgetStore = create()( addWidget: (type) => set((state) => ({ - widgets: [ - ...state.widgets, - { id: `${type}-${Date.now()}`, type }, - ], + widgets: [...state.widgets, { id: `${type}-${Date.now()}`, type }], })), removeWidget: (id) => @@ -157,58 +88,35 @@ export const useWidgetStore = create()( toggleSpan: (id) => set((state) => ({ - widgets: state.widgets.map((w, index) => { + widgets: state.widgets.map((w) => { if (w.id !== id) return w; - const current = getWidgetLayout(w, index); - const nextWidth = current.w > 3 ? 3 : 6; - const nextHeight = Math.max(current.h, getWidgetMinHeight(w.type, nextWidth)); - return { - ...w, - span: nextWidth === 6 ? 2 : 1, - layout: { - ...current, - x: Math.min(current.x, 12 - nextWidth), - w: nextWidth, - h: nextHeight, - }, - }; + const footprint = getWidgetFootprint(w.type); + if (!footprint.resizable) return w; + const current = w.colSpan ?? footprint.colSpan; + return { ...w, colSpan: current >= 2 ? 1 : 2 }; }), })), - syncLayouts: (layouts) => - set((state) => { - let changed = false; - const widgets = state.widgets.map((w) => { - const next = layouts[w.id]; - if (!isDesktopLayout(next)) return w; - if ( - w.layout?.x === next.x && - w.layout?.y === next.y && - w.layout?.w === next.w && - w.layout?.h === next.h - ) { - return w; - } - changed = true; - return { ...w, layout: next }; - }); - return changed ? { widgets } : state; - }), - resetToDefault: () => set({ widgets: DEFAULT_WIDGETS }), }), { name: "minutia-widgets", - version: 2, + version: 3, + // Drop GridStack-era layout fields (x/y/w/h, span). Keep id/type/colSpan + // and preserve order. migrate: (persisted) => { - const state = persisted as { widgets?: StoredWidgetInstance[] } | undefined; - return { - widgets: (state?.widgets ?? DEFAULT_WIDGETS).map(({ - layout: _layout, - span: _span, - ...widget - }) => widget), - }; + const state = persisted as { widgets?: Array> } | undefined; + const widgets = (state?.widgets ?? DEFAULT_WIDGETS).map((w) => { + const colSpan = w.colSpan; + return { + id: String(w.id), + type: String(w.type), + ...(colSpan === 1 || colSpan === 2 || colSpan === 3 || colSpan === 4 + ? { colSpan: colSpan as ColSpan } + : {}), + }; + }); + return { widgets }; }, } )