Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9af1fa1
refactor(web): polish gauge widget visuals
ZingerLittleBee May 27, 2026
29f912e
fix(web): harden metric-card config against missing metric
ZingerLittleBee May 27, 2026
2d9e85f
feat(web): make square widget cells pixel-square in the grid
ZingerLittleBee May 27, 2026
fb22a20
style(web): de-emphasize the % suffix in the gauge value
ZingerLittleBee May 27, 2026
78e3cf6
docs: spec for dashboard widget sizing strategy
ZingerLittleBee May 27, 2026
193ed37
docs: revise widget sizing spec for two-layer model
ZingerLittleBee May 27, 2026
6cb0ce8
Merge remote-tracking branch 'origin/main' into lisbon
ZingerLittleBee May 27, 2026
b0d7039
docs: add dashboard widget sizing strategy implementation plan
ZingerLittleBee May 27, 2026
379edb5
refactor(web): extract dashboard grid constants for sharing
ZingerLittleBee May 27, 2026
24055e0
feat(web): declare sizing strategy on every widget type
ZingerLittleBee May 27, 2026
12f0b67
feat(web): add nearestTier helper for sizing strategies
ZingerLittleBee May 27, 2026
25691d3
feat(web): add applyCoarsePatch helper for sizing strategies
ZingerLittleBee May 27, 2026
ae3ed8a
feat(web): add normalizeRenderItem for idle widget sizing (layer A)
ZingerLittleBee May 27, 2026
1fff597
feat(web): add applyStrategy with RGL constraints (layer B)
ZingerLittleBee May 27, 2026
b8b1630
feat(web): add snapOnRelease for aspect-square tier snap
ZingerLittleBee May 27, 2026
645a075
feat(web): snap aspect-square widgets to nearest tier in widgetsToLayout
ZingerLittleBee May 27, 2026
620123c
refactor(web): route dashboard grid sizing through strategy dispatcher
ZingerLittleBee May 27, 2026
355ee38
chore(web): satisfy useDefaultSwitchClause lint rule for sizing strat…
ZingerLittleBee May 27, 2026
f812473
docs: add widget sizing contributor + manual verification checklists
ZingerLittleBee May 27, 2026
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
133 changes: 73 additions & 60 deletions apps/web/src/components/dashboard/dashboard-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ import 'react-grid-layout/css/styles.css'
import { Button } from '@/components/ui/button'
import type { ServerMetrics } from '@/hooks/use-servers-ws'
import { cn } from '@/lib/utils'
import type { DashboardWidget } from '@/lib/widget-types'
import type { DashboardWidget, SizingStrategy, WidgetTypeDefinition } from '@/lib/widget-types'
import { WIDGET_TYPES } from '@/lib/widget-types'
import { layoutToPatch, widgetsToLayout } from './dashboard-layout'
import { COLS, MARGIN, MARGIN_Y, ROW_HEIGHT, SCALE } from './grid-constants'
import { applyCoarsePatch, applyStrategy, snapOnRelease } from './sizing-strategies'
import { normalizeRenderItem } from './sizing-strategies/normalize'
import { VisibilityGate } from './visibility-gate'
import { WidgetRenderer } from './widget-renderer'

Expand All @@ -43,26 +47,11 @@ function isWidgetStatic(configJson: string): boolean {
}
}

const COLS = 12
// Vertical fine-grain factor: persisted (coarse) grid rows are split into SCALE
// finer rows so content-sized widgets quantize to ~ROW_HEIGHT px instead of a
// whole coarse row. Invariant for pixel-identical legacy widgets: the legacy
// per-row step (80 + 16) must equal SCALE * (ROW_HEIGHT + MARGIN_Y) → 4*(8+16)=96.
const SCALE = 4
const ROW_HEIGHT = 8
const MARGIN: [number, number] = [16, 16]
const MARGIN_Y = MARGIN[1]
// Legacy coarse row pixel height, used only for the mobile single-column min-height.
const MOBILE_ROW_PX = 80
const MOBILE_BREAKPOINT = 768

// Widgets whose grid cell height should follow their measured content height
// instead of a fixed/estimated number of rows.
const AUTO_HEIGHT_TYPES = new Set(['top-n'])

// Widgets that must stay square (1:1 in coarse grid units). Width and height
// are locked together during resize so the radial visual stays balanced.
const SQUARE_TYPES = new Set(['gauge'])
const WIDGET_TYPE_MAP = new Map<string, WidgetTypeDefinition>(WIDGET_TYPES.map((widget) => [widget.id, widget]))

function pxToGridUnits(px: number): number {
return Math.max(2, Math.ceil((px + MARGIN_Y) / (ROW_HEIGHT + MARGIN_Y)))
Expand Down Expand Up @@ -189,9 +178,18 @@ export function DashboardGrid({
setAutoUnits((prev) => (prev[id] === units ? prev : { ...prev, [id]: units }))
}, [])

const squareIdSet = useMemo(
() => new Set(widgets.filter((w) => SQUARE_TYPES.has(w.widget_type)).map((w) => w.id)),
[widgets]
const widgetById = useMemo(() => new Map(widgets.map((w) => [w.id, w])), [widgets])

const getStrategy = useCallback(
(itemId: string): SizingStrategy => {
const widget = widgetById.get(itemId)
if (!widget) {
return { kind: 'free' }
}
const def = WIDGET_TYPE_MAP.get(widget.widget_type)
return def?.sizing ?? { kind: 'free' }
},
[widgetById]
)

// Persisted grid units are coarse (1 row == ROW_HEIGHT*SCALE px). The grid
Expand All @@ -209,22 +207,33 @@ export function DashboardGrid({
if (item.maxH !== undefined) {
item.maxH *= SCALE
}
const units = autoUnits[item.i]
if (units !== undefined) {
// Auto-height widgets fit their content exactly: height is locked to the
// measured content and cannot be adjusted. Only horizontal resize stays.
item.minH = units
item.maxH = units
item.h = units
item.resizeHandles = ['e']

const strategy = getStrategy(item.i)
const measured = autoUnits[item.i]

// Layer A: idle h / minH / maxH per strategy.
const normalized = normalizeRenderItem(item, strategy, {
containerWidth: width,
autoMeasuredFineH: measured
})
item.h = normalized.h
item.minH = normalized.minH
item.maxH = normalized.maxH

// Layer B: resize-time constraints, handles, resizability.
const desc = applyStrategy(strategy, measured)
if (desc.constraints.length > 0) {
item.constraints = desc.constraints
}
if (desc.resizeHandles) {
item.resizeHandles = desc.resizeHandles
}
if (squareIdSet.has(item.i)) {
// Square widgets resize on the corner only and snap to w === h on commit.
item.resizeHandles = ['se']
if (!desc.isResizable) {
item.isResizable = false
}
}
return deoverlapLayout(layout)
}, [widgets, autoUnits, squareIdSet])
}, [widgets, autoUnits, width, getStrategy])

const [liveLayout, setLiveLayout] = useState<Layout>(baseLayout)
const [interactionState, setInteractionState] = useState<InteractionState>('idle')
Expand All @@ -241,11 +250,6 @@ export function DashboardGrid({
}
const widgetServers = shouldFreeze ? frozenServersRef.current : servers

const autoIdSet = useMemo(
() => new Set(widgets.filter((w) => AUTO_HEIGHT_TYPES.has(w.widget_type)).map((w) => w.id)),
[widgets]
)

// No auto-compaction (widgets stay exactly where dropped, so items in
// different columns can be aligned freely) and preventCollision blocks
// dropping onto another widget (it snaps back), so widgets never overlap.
Expand All @@ -255,19 +259,20 @@ export function DashboardGrid({
// whole coarse rows (SCALE-aligned) while dragging/resizing. Otherwise the
// dropped fine position gets rounded on commit and the widget visibly snaps
// back, then ping-pongs with RGL's onLayoutChange echo (the "jitter").
// Auto-height widgets keep their measured fine height untouched.
// content-height widgets keep their measured fine height untouched.
const updateLiveLayout = useCallback(
(nextLayout: Layout) => {
const snapped = nextLayout.map((item) => {
const strategy = getStrategy(item.i)
const base = {
...item,
y: Math.round(item.y / SCALE) * SCALE,
h: autoIdSet.has(item.i) ? item.h : Math.max(SCALE, Math.round(item.h / SCALE) * SCALE)
y: Math.round(item.y / SCALE) * SCALE
}
if (squareIdSet.has(item.i)) {
// Lock height to width (in fine units, h = w * SCALE) so the gauge
// stays a coarse-unit square no matter which dimension the user drags.
base.h = base.w * SCALE
// Snap h to SCALE multiples only for strategies that operate at coarse h.
// aspect-square: h is fine pixel-square (RGL constraints handle resize); leave it.
// content-height: h locked to measurement; leave it.
if (strategy.kind === 'free' || strategy.kind === 'fixed') {
base.h = Math.max(SCALE, Math.round(item.h / SCALE) * SCALE)
}
return base
})
Expand All @@ -276,7 +281,7 @@ export function DashboardGrid({
// raw (overlapping) persisted positions over the de-overlapped layout.
setLiveLayout(deoverlapLayout(snapped))
},
[autoIdSet, squareIdSet]
[getStrategy]
)

// Resync the rendered layout to the widgets-derived one the moment `widgets`
Expand Down Expand Up @@ -305,25 +310,33 @@ export function DashboardGrid({
const commitLayoutChange = useCallback(
(finalLayout: Layout) => {
setInteractionState('idle')
// Convert the fine grid back to coarse persisted units. Auto-height widgets
// persist their height too so a user-grown size sticks (the measured
// content height still acts as the floor on the next render).
// Snap to coarse rows then resolve any residual penetration. preventCollision
// can block the move so no patch is emitted; without this the live layout
// would keep the penetrating drag position until the next widgets change.

// Per-strategy snap. For free/fixed: snap h to coarse multiples. For
// aspect-square: apply snapOnRelease's coarse SnapPatch via applyCoarsePatch
// (sets w and re-derives fine h via SCALE). Then re-normalize so the live
// layout matches what the next baseLayout render will produce.
const snapped = finalLayout.map((item) => {
const base = {
const strategy = getStrategy(item.i)
const measured = autoUnits[item.i]

let base = {
...item,
y: Math.round(item.y / SCALE) * SCALE,
h: autoIdSet.has(item.i) ? item.h : Math.max(SCALE, Math.round(item.h / SCALE) * SCALE)
y: Math.round(item.y / SCALE) * SCALE
}
if (squareIdSet.has(item.i)) {
base.h = base.w * SCALE
if (strategy.kind === 'free' || strategy.kind === 'fixed') {
base.h = Math.max(SCALE, Math.round(item.h / SCALE) * SCALE)
}
return base

const snap = snapOnRelease(base, strategy, { containerWidth: width })
base = applyCoarsePatch(base, snap)
return normalizeRenderItem(base, strategy, {
containerWidth: width,
autoMeasuredFineH: measured
})
})
const resolved = deoverlapLayout(snapped)
setLiveLayout(resolved)

const coarseLayout = resolved.map((item) => ({
...item,
y: Math.round(item.y / SCALE),
Expand All @@ -334,7 +347,7 @@ export function DashboardGrid({
onLayoutChange(patch)
}
},
[autoIdSet, squareIdSet, onLayoutChange, widgets]
[autoUnits, getStrategy, onLayoutChange, widgets, width]
)

const sortedWidgets = useMemo(() => {
Expand All @@ -345,7 +358,7 @@ export function DashboardGrid({
return (
<div className="space-y-4">
{sortedWidgets.map((widget) => {
const isAuto = AUTO_HEIGHT_TYPES.has(widget.widget_type)
const isAuto = getStrategy(widget.id).kind === 'content-height'
return (
<div className="relative" key={widget.id}>
{isEditing && (
Expand Down Expand Up @@ -393,7 +406,7 @@ export function DashboardGrid({
width={width}
>
{widgets.map((widget) => {
const isAuto = AUTO_HEIGHT_TYPES.has(widget.widget_type)
const isAuto = getStrategy(widget.id).kind === 'content-height'
return (
<div className="relative h-full" key={widget.id}>
{isEditing && (
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/components/dashboard/dashboard-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ import { describe, expect, it } from 'vitest'
import type { DashboardWidget } from '@/lib/widget-types'
import { layoutToPatch, mergeLayoutPatch, normalizeNewWidgetPlacement, widgetsToLayout } from './dashboard-layout'

function makeWidget(overrides: Partial<DashboardWidget>): DashboardWidget {
return {
id: 'w1',
dashboard_id: 'd1',
widget_type: 'gauge',
grid_x: 0,
grid_y: 0,
grid_w: 2,
grid_h: 2,
sort_order: 0,
title: null,
config_json: '{}',
created_at: '2026-05-28T00:00:00Z',
...overrides
}
}

const widgets: DashboardWidget[] = [
{
id: 'w-1',
Expand Down Expand Up @@ -31,6 +48,41 @@ const widgets: DashboardWidget[] = [
}
]

describe('widgetsToLayout', () => {
describe('aspect-square clamp', () => {
it('snaps a non-square gauge to the nearest tier (uses max of w/h)', () => {
// grid_w=3, grid_h=5 → max=5 → nearest tier in [2,3,4,5,6] is 5
const widget = makeWidget({ grid_w: 3, grid_h: 5 })
const [item] = widgetsToLayout([widget])
expect(item.w).toBe(5)
expect(item.h).toBe(5)
})

it('preserves a gauge already at a legal tier', () => {
const widget = makeWidget({ grid_w: 4, grid_h: 4 })
const [item] = widgetsToLayout([widget])
expect(item.w).toBe(4)
expect(item.h).toBe(4)
})

it('clamps an oversized gauge to maxW tier', () => {
// grid_w=10 → clamped to maxW=6 by existing logic → tier=6
const widget = makeWidget({ grid_w: 10, grid_h: 10 })
const [item] = widgetsToLayout([widget])
expect(item.w).toBe(6)
expect(item.h).toBe(6)
})

it('does not snap non-aspect-square widgets', () => {
// metric-card is free; should keep grid_w/h as-is (within min/max)
const widget = makeWidget({ widget_type: 'metric-card', grid_w: 4, grid_h: 3 })
const [item] = widgetsToLayout([widget])
expect(item.w).toBe(4)
expect(item.h).toBe(3)
})
})
})

describe('dashboard-layout', () => {
it('widgetsToLayout adds min and max constraints from widget definitions', () => {
const layout = widgetsToLayout(widgets)
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/dashboard/dashboard-layout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Layout, LayoutItem } from 'react-grid-layout'
import type { DashboardWidget, WidgetTypeDefinition } from '@/lib/widget-types'
import { WIDGET_TYPES } from '@/lib/widget-types'
import { nearestTier } from './sizing-strategies'

export interface LayoutPatch {
grid_h: number
Expand Down Expand Up @@ -42,6 +43,17 @@ export function widgetsToLayout(widgets: DashboardWidget[]): Layout {
if (maxH !== undefined) {
h = Math.min(h, maxH)
}

// Aspect-square widgets persist as w_coarse === h_coarse. If historical data
// somehow has them out of sync (or grid_w not at a legal tier), snap here so
// the cell renders square on first load. Next user drag/resize writes back.
const sizing = WIDGET_TYPE_MAP.get(widget.widget_type)?.sizing
if (sizing?.kind === 'aspect-square') {
const tier = nearestTier(Math.max(w, h), sizing.tiers)
w = tier
h = tier
}

const item: LayoutItem = {
i: widget.id,
x: widget.grid_x,
Expand All @@ -57,9 +69,6 @@ export function widgetsToLayout(widgets: DashboardWidget[]): Layout {
if (maxH !== undefined) {
item.maxH = maxH
}
if (maxW !== undefined && maxH !== undefined && minW === maxW && minH === maxH) {
item.isResizable = false
}
if (isWidgetStatic(widget.config_json)) {
item.static = true
}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/dashboard/grid-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Grid layout primitives shared between dashboard-grid.tsx and sizing-strategies/.
// Persisted (coarse) grid rows are split into SCALE finer rows so content-sized
// widgets quantize to ~ROW_HEIGHT px instead of a whole coarse row.
// Invariant for pixel-identical legacy widgets: legacy per-row step
// (80 + 16) must equal SCALE * (ROW_HEIGHT + MARGIN_Y) → 4 * (8 + 16) = 96.
export const COLS = 12
export const SCALE = 4
export const ROW_HEIGHT = 8
export const MARGIN: readonly [number, number] = [16, 16]
export const MARGIN_X = MARGIN[0]
export const MARGIN_Y = MARGIN[1]
Loading
Loading