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
128 changes: 128 additions & 0 deletions docs/superpowers/specs/2026-06-07-widget-dashboard-bento-design.md
Original file line number Diff line number Diff line change
@@ -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).
82 changes: 82 additions & 0 deletions e2e/regression/widget-bento.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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<HTMLElement>("[data-testid^='widget-']").forEach((widget) => {
const id = widget.getAttribute("data-testid");
widget.querySelectorAll<HTMLElement>("*").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);
});
});
72 changes: 36 additions & 36 deletions e2e/regression/widgets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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"));

Expand All @@ -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(
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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
Expand All @@ -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 }) => {
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading