Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.
Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
pass places hidden widgets after the visible pass — collision-aware
+ cursor-aware so the hidden tray stacks below the active layout
without ever displacing a visible widget. applyLayout() passes
includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
their stored coordinates so un-hiding restores them in place).
Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
catalog Min/Max + grid bounds on load. Stale rows with W below MinW
(or above MaxW, or X+W overflowing the grid) heal on the next
/api/me/dashboard-layout GET and the cleaned spec is persisted
back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
exists to recover users who got into a bad state under the old
rules.
Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
behaviour (hidden skipped by default, two-pass ordering, multi-
hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
W=0 sentinel, negative X) plus a round-trip Validate guarantee.
265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
// dashboard-grid — pure layout math for the dashboard widget grid.
|
||
//
|
||
// Lives outside dashboard.ts so the placement logic is importable from
|
||
// tests without dragging in the DOM-side rendering code. The grid is a
|
||
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
|
||
// rows grow vertically as widgets are placed.
|
||
//
|
||
// The core invariant is no-overlap: after placeWidgets() returns, every
|
||
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
|
||
// computePlacements() to trust explicit (x, y) without checking — that
|
||
// produced visual overlap whenever a drag or resize landed a widget on
|
||
// cells another widget already covered (m/paliad#70). The collision-
|
||
// aware placer below shifts colliding widgets to the next free row so
|
||
// the rendered grid never overlaps regardless of the input spec.
|
||
|
||
export const GRID_COLUMNS = 12;
|
||
export const MAX_ROW_SPAN = 5;
|
||
|
||
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
|
||
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
|
||
// at least one widget, so 256 rows is an order-of-magnitude buffer
|
||
// against runaway loops on pathological inputs.
|
||
const MAX_SCAN_ROWS = 256;
|
||
|
||
export interface PlacedRect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
// WidgetSizeBound captures the per-widget min/max/default clamps the
|
||
// catalog publishes. Optional fields keep callers from having to
|
||
// synthesize zeroes when the catalog entry is missing.
|
||
export interface WidgetSizeBound {
|
||
default_w?: number;
|
||
default_h?: number;
|
||
min_w?: number;
|
||
max_w?: number;
|
||
min_h?: number;
|
||
max_h?: number;
|
||
}
|
||
|
||
// WidgetPlacementInput is the per-widget data the placer consumes. The
|
||
// catalog bound is optional — when missing, defaults fall back to a
|
||
// full-width 1-row widget.
|
||
export interface WidgetPlacementInput {
|
||
key: string;
|
||
visible: boolean;
|
||
x?: number;
|
||
y?: number;
|
||
w?: number;
|
||
h?: number;
|
||
bound?: WidgetSizeBound;
|
||
}
|
||
|
||
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
|
||
let v = Math.round(w);
|
||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
|
||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
|
||
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
|
||
return v;
|
||
}
|
||
|
||
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
|
||
let v = Math.round(h);
|
||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
|
||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
|
||
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
|
||
return v;
|
||
}
|
||
|
||
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
|
||
// created lazily so the map only stores rows the layout actually
|
||
// reaches. Cell value 1 = occupied.
|
||
class Occupancy {
|
||
private rows = new Map<number, Uint8Array>();
|
||
|
||
row(y: number): Uint8Array {
|
||
let r = this.rows.get(y);
|
||
if (!r) {
|
||
r = new Uint8Array(GRID_COLUMNS);
|
||
this.rows.set(y, r);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
free(x: number, y: number, w: number, h: number): boolean {
|
||
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
|
||
for (let yy = y; yy < y + h; yy++) {
|
||
const row = this.row(yy);
|
||
for (let xx = x; xx < x + w; xx++) {
|
||
if (row[xx]) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
mark(x: number, y: number, w: number, h: number): void {
|
||
for (let yy = y; yy < y + h; yy++) {
|
||
const row = this.row(yy);
|
||
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// findFreeSlot scans for the first (x, y) where a w×h block fits without
|
||
// collision, starting at row startY. At each row preferX is tried first
|
||
// — that keeps a widget close to its requested column when only the row
|
||
// is blocked. Falls back to left-to-right scan within the row, then to
|
||
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
|
||
function findFreeSlot(
|
||
occ: Occupancy,
|
||
startY: number,
|
||
w: number,
|
||
h: number,
|
||
preferX: number,
|
||
): { x: number; y: number } {
|
||
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
|
||
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
|
||
return { x: preferX, y };
|
||
}
|
||
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
|
||
if (x === preferX) continue;
|
||
if (occ.free(x, y, w, h)) return { x, y };
|
||
}
|
||
}
|
||
// Pathological fallback — caller's widget cap (32) makes this
|
||
// unreachable in practice. Snap to the bottom-left so the widget at
|
||
// least renders somewhere visible instead of vanishing.
|
||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||
}
|
||
|
||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||
// needs.
|
||
export interface PlaceOptions {
|
||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||
// where the user can see + un-hide them inline. The two-pass order
|
||
// (visible first, then hidden) guarantees hidden widgets never
|
||
// displace visible ones: they get whatever cells are left below the
|
||
// active layout. Default false matches view-mode behaviour and the
|
||
// persistence path (materializePositions) where hidden widgets
|
||
// retain their stored coordinates instead of being repacked.
|
||
//
|
||
// Without this option, hidden widgets in edit mode were left without
|
||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||
includeHidden?: boolean;
|
||
}
|
||
|
||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||
// default only visible widgets receive placements; pass
|
||
// {includeHidden:true} to also place hidden widgets after the visible
|
||
// pass (used by applyLayout in edit mode).
|
||
//
|
||
// Algorithm — per pass:
|
||
// 1. Clamp w/h against catalog bounds.
|
||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||
// collision, search downward starting at the requested y for the
|
||
// first free w×h block (preferring the requested x).
|
||
// 3. If only x is explicit, search from y=0 at that x.
|
||
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
|
||
// when the row doesn't fit or is blocked by an explicitly-placed
|
||
// widget, wrap to the next free row.
|
||
//
|
||
// The mixed-spec case (some widgets explicit, others auto-flow) is the
|
||
// real-world layout — placing the explicit widgets first would change
|
||
// the visual order, so we keep input order and let auto-flow widgets
|
||
// step around any explicit blockers via the same collision search.
|
||
//
|
||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||
// own auto-flow cursor; the hidden pass continues from where the
|
||
// visible pass left off so the hidden widgets stack right under the
|
||
// active layout. The shared Occupancy bitmap guarantees the second
|
||
// pass can never overlap a placed visible widget.
|
||
export function placeWidgets(
|
||
widgets: WidgetPlacementInput[],
|
||
options: PlaceOptions = {},
|
||
): Map<string, PlacedRect> {
|
||
const out = new Map<string, PlacedRect>();
|
||
const occ = new Occupancy();
|
||
|
||
// Auto-flow cursor — advances as we place flowed widgets. cursorY
|
||
// tracks the row currently being filled; rowMaxH is the tallest
|
||
// widget in that row so wrapping advances past it (not just past the
|
||
// new widget's height — that would let taller previous neighbours
|
||
// overlap into the wrap row).
|
||
let cursorX = 0;
|
||
let cursorY = 0;
|
||
let rowMaxH = 0;
|
||
|
||
const placeOne = (w: WidgetPlacementInput): void => {
|
||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||
|
||
const hasX = typeof w.x === "number";
|
||
const hasY = typeof w.y === "number";
|
||
let placed: { x: number; y: number };
|
||
|
||
if (hasX && hasY) {
|
||
// Clamp x so the widget never overflows the right edge — drag/
|
||
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
|
||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||
const prefY = Math.max(0, w.y as number);
|
||
if (occ.free(prefX, prefY, dw, dh)) {
|
||
placed = { x: prefX, y: prefY };
|
||
} else {
|
||
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
|
||
}
|
||
} else if (hasX) {
|
||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||
placed = findFreeSlot(occ, 0, dw, dh, prefX);
|
||
} else {
|
||
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
|
||
// remaining columns of the current row, then ask findFreeSlot to
|
||
// honour the cursor's preferred (x, y) — that lets it step past
|
||
// any explicit widget that already claimed cells under the
|
||
// cursor.
|
||
if (cursorX + dw > GRID_COLUMNS) {
|
||
cursorY += rowMaxH || 1;
|
||
cursorX = 0;
|
||
rowMaxH = 0;
|
||
}
|
||
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
|
||
if (placed.y > cursorY) {
|
||
// Wrap was forced by a collision deeper than the current row.
|
||
cursorY = placed.y;
|
||
rowMaxH = 0;
|
||
}
|
||
cursorX = placed.x + dw;
|
||
if (dh > rowMaxH) rowMaxH = dh;
|
||
}
|
||
|
||
occ.mark(placed.x, placed.y, dw, dh);
|
||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||
};
|
||
|
||
// Pass 1: visible widgets. They own the active layout.
|
||
for (const w of widgets) {
|
||
if (!w.visible) continue;
|
||
placeOne(w);
|
||
}
|
||
|
||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||
// start of the next row before the second pass so the hidden tray
|
||
// visually separates from the active layout — even if the last
|
||
// visible widget left half a row open.
|
||
if (options.includeHidden) {
|
||
if (cursorX > 0) {
|
||
cursorY += rowMaxH || 1;
|
||
cursorX = 0;
|
||
rowMaxH = 0;
|
||
}
|
||
for (const w of widgets) {
|
||
if (w.visible) continue;
|
||
placeOne(w);
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|