Edit the whole dashboard, and let the grid follow its canvas

The editor only ever arranged the first section, so the demo's Home
dashboard lost 16 of its 25 widgets the moment it was edited. A page's
sections are now read as one arrangement — each pushed below the one
before it — and written back as one, which is the shape a dashboard was
already heading for: one dashboard, one canvas, and the panel rail for
the several-dashboards story. The page tabs that story made dead are
gone; PageDef/SectionDef stay in the schema and a page the editor does
not show round-trips untouched.

The row height derives from the canvas as the column width already did,
so the same arrangement is the same picture on a 7" panel as on a 4K
one. The uPlot rules move beside UplotChart, where a chart on Health is
styled without a dashboard having been visited first, and the bar's
readout travels on one property instead of jumping sides at 30%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 12:22:37 +02:00
co-authored by Claude Opus 5
parent 48955a6787
commit eb2e6bf6d2
6 changed files with 216 additions and 162 deletions
@@ -2,6 +2,11 @@ import { useEffect, useLayoutEffect, useRef } from "react"
import uPlot from "uplot" import uPlot from "uplot"
import "uplot/dist/uPlot.min.css" import "uplot/dist/uPlot.min.css"
// After uPlot's own sheet, and beside this component rather than in the
// dashboard chunk: a chart on Health or Home is styled without a dashboard
// having been visited first.
import "./uplot.css"
import type { HistoryPoint } from "@/client" import type { HistoryPoint } from "@/client"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
+48
View File
@@ -0,0 +1,48 @@
/*
* uPlot, routed through the design tokens.
*
* Beside `UplotChart.tsx` and imported by it, so every chart is styled wherever
* it is drawn. These rules used to live in the dashboard's own CSS chunk, which
* left a chart on Health or Home unstyled until a dashboard had been visited in
* that session.
*
* Its own legend is the hover readout as well — the value each line carried at
* the cursor — so it is styled as chart furniture rather than replaced.
*/
.u-legend {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
/* Series labels default to the message name, which has no spaces to break at
— and a table cannot lay out below its min-content width. */
.u-legend th {
overflow-wrap: anywhere;
}
.u-legend .u-marker {
width: 0.5rem;
height: 0.5rem;
border-width: 2px;
border-radius: 9999px;
}
.u-legend .u-value {
font-variant-numeric: tabular-nums;
color: var(--foreground);
}
.u-legend .u-series.u-off {
opacity: 0.4;
}
.u-cursor-x,
.u-cursor-y {
border-color: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
}
.u-select {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
@@ -146,16 +146,19 @@ export function BarWidget({ widget }: WidgetProps) {
}} }}
/> />
))} ))}
{/* Always anchored to the end of the fill by `left`, so the whole
travel is one interpolating property. Which side of that anchor the
reading sits on is the translate: pulled back onto the fill while
there is room for it, left where it is once there is not. */}
<span <span
className={cn( className={cn(
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums motion-safe:transition-[left,right] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]", "absolute inset-y-0 flex items-center px-2 text-sm tabular-nums motion-safe:transition-[left,transform] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]",
fits ? "text-primary-foreground" : "text-foreground", fits ? "text-primary-foreground" : "text-foreground",
)} )}
style={ style={{
fits left: `${fraction * 100}%`,
? { right: `${(1 - fraction) * 100}%` } transform: fits ? "translateX(-100%)" : "translateX(0)",
: { left: `${fraction * 100}%` } }}
}
> >
{write(value)} {write(value)}
</span> </span>
@@ -42,7 +42,6 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -60,11 +59,12 @@ import {
columnsOf, columnsOf,
type Dashboard, type Dashboard,
DashboardView, DashboardView,
flatWidgets,
GRID_GAP, GRID_GAP,
isPlaced, isPlaced,
pagesOf, pagesOf,
placement, placement,
ROW_HEIGHT, rowHeightOf,
rowsOf, rowsOf,
sectionsOf, sectionsOf,
widgetsOf, widgetsOf,
@@ -208,9 +208,6 @@ export function DashboardEditor({
const [draft, setDraft] = useState<Dashboard>(dashboard) const [draft, setDraft] = useState<Dashboard>(dashboard)
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [pageId, setPageId] = useState<string | undefined>(
() => pagesOf(dashboard)[0]?.id,
)
// A phone reads the dashboard rather than arranges it, so the grid library // A phone reads the dashboard rather than arranges it, so the grid library
// never mounts there. See DESIGN-GUIDELINES.md → Responsive. // never mounts there. See DESIGN-GUIDELINES.md → Responsive.
const stacked = useIsMobile() const stacked = useIsMobile()
@@ -283,26 +280,32 @@ export function DashboardEditor({
// a save in flight is still "no unpublished changes" until it lands. // a save in flight is still "no unpublished changes" until it lands.
const hasDraft = Boolean(dashboard.has_draft) const hasDraft = Boolean(dashboard.has_draft)
const columns = columnsOf(draft) const columns = columnsOf(draft)
const pages = pagesOf(draft) const page = pagesOf(draft)[0]
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] // Every widget the panel shows, not just the first section's — a document
const section = page ? sectionsOf(page)[0] : undefined // written with several sections is one arrangement here, as it is there.
const widgets = section ? widgetsOf(section) : [] const widgets = page ? flatWidgets(page) : []
const canvas = canvasOf(draft) const canvas = canvasOf(draft)
const updateWidgets = (next: WidgetDef[]) => { const updateWidgets = (next: WidgetDef[]) => {
if (!page || !section) return if (!page) return
commit({ commit({
...draft, ...draft,
// Only this page is rewritten: a document carrying pages the editor
// does not show round-trips them untouched.
pages: pagesOf(draft).map((candidate) => pages: pagesOf(draft).map((candidate) =>
candidate.id !== page.id candidate.id !== page.id
? candidate ? candidate
: { : {
...candidate, ...candidate,
sections: sectionsOf(candidate).map((existing) => // One canvas, one grid: what several sections were read as is
existing.id !== section.id // written back as one, which is also what keeps their row
? existing // offsets from being applied a second time on the next read.
: { ...existing, widgets: next }, sections: [
), {
...(sectionsOf(candidate)[0] ?? { id: "main" }),
widgets: next,
},
],
}, },
), ),
}) })
@@ -444,7 +447,6 @@ export function DashboardEditor({
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<DashboardView <DashboardView
dashboard={draft} dashboard={draft}
pageId={page.id}
stacked stacked
renderWidget={edit ? pickable : undefined} renderWidget={edit ? pickable : undefined}
/> />
@@ -453,7 +455,7 @@ export function DashboardEditor({
<CanvasSurface dashboard={draft} dots={edit}> <CanvasSurface dashboard={draft} dots={edit}>
{(scale) => {(scale) =>
!edit ? ( !edit ? (
<DashboardView dashboard={draft} pageId={page.id} /> <DashboardView dashboard={draft} />
) : ( ) : (
<GridLayout <GridLayout
width={canvas.width} width={canvas.width}
@@ -462,7 +464,7 @@ export function DashboardEditor({
positionStrategy={scaledStrategy(scale)} positionStrategy={scaledStrategy(scale)}
gridConfig={{ gridConfig={{
cols: columns, cols: columns,
rowHeight: ROW_HEIGHT, rowHeight: rowHeightOf(draft),
margin: [GRID_GAP, GRID_GAP], margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0], containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the // The canvas is the constraint: nothing may be dragged off the
@@ -524,17 +526,6 @@ export function DashboardEditor({
<span className="truncate px-3 py-1.5 text-sm font-medium"> <span className="truncate px-3 py-1.5 text-sm font-medium">
{draft.title || draft.name} {draft.title || draft.name}
</span> </span>
{pages.length > 1 ? (
<Tabs value={page?.id} onValueChange={setPageId}>
<TabsList>
{pages.map((candidate) => (
<TabsTrigger key={candidate.id} value={candidate.id}>
{candidate.title || candidate.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
</CanvasTitle> </CanvasTitle>
<motion.div <motion.div
@@ -31,15 +31,9 @@ export const CANVAS_PRESETS = [
{ label: "Full HD, portrait", width: 1080, height: 1920 }, { label: "Full HD, portrait", width: 1080, height: 1920 },
] ]
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const ROW_HEIGHT = 80
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */ /** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12 export const GRID_GAP = 12
/** Distance between two row origins — what a widget's `y` is counted in. */
export const ROW_PITCH = ROW_HEIGHT + GRID_GAP
/** /**
* The generated client marks every list optional, because the server fills * The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components. * them in. These three keep that from spreading through the components.
@@ -56,9 +50,43 @@ export const canvasOf = (dashboard: Dashboard) => ({
height: dashboard.canvas_height || DEFAULT_CANVAS.height, height: dashboard.canvas_height || DEFAULT_CANVAS.height,
}) })
/** One grid row, in pixels, on the canvas the grid was first drawn for. */
const DEFAULT_ROW_HEIGHT = 80
/** A column's own width on that canvas, gaps taken off. */
const DEFAULT_COLUMN_WIDTH =
(DEFAULT_CANVAS.width + GRID_GAP) / DEFAULT_COLUMNS - GRID_GAP
/**
* How much wider a cell is than it is tall.
*
* A column's width already follows the canvas — it is the canvas cut into
* `columns` — while the row height was a fixed 80px, so the same arrangement
* came out as a different picture on every panel: cells taller than wide on a
* 7" one, twice as wide as tall on a 4K one. Holding the proportion instead is
* what makes the canvas worth scaling to fit. The number is the one the
* default canvas already had rather than a new one, so a panel that says
* nothing about its size keeps exactly the 80px rows it had.
*/
const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
const columnWidthOf = (dashboard: Dashboard) =>
(canvasOf(dashboard).width + GRID_GAP) / columnsOf(dashboard) - GRID_GAP
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const rowHeightOf = (dashboard: Dashboard) =>
Math.round(columnWidthOf(dashboard) / CELL_ASPECT)
/** Distance between two row origins — what a widget's `y` is counted in. */
export const rowPitchOf = (dashboard: Dashboard) =>
rowHeightOf(dashboard) + GRID_GAP
/** How many rows of the grid fit in the canvas; the rest is off the panel. */ /** How many rows of the grid fit in the canvas; the rest is off the panel. */
export const rowsOf = (dashboard: Dashboard) => export const rowsOf = (dashboard: Dashboard) =>
Math.max(1, Math.floor((canvasOf(dashboard).height + GRID_GAP) / ROW_PITCH)) Math.max(
1,
Math.floor((canvasOf(dashboard).height + GRID_GAP) / rowPitchOf(dashboard)),
)
/** /**
* The dashboard's own surface: exactly the panel's pixel size, scaled to fit * The dashboard's own surface: exactly the panel's pixel size, scaled to fit
@@ -116,7 +144,7 @@ export function CanvasSurface({
// One dot per cell corner. The column pitch the grid snaps to is // One dot per cell corner. The column pitch the grid snaps to is
// (width + gap) / columns; a row is one row plus the gap. // (width + gap) / columns; a row is one row plus the gap.
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`, "--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${ROW_PITCH}px`, "--dot-y": `${rowPitchOf(dashboard)}px`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
@@ -164,79 +192,73 @@ export const isPlaced = (widgets: WidgetDef[]) =>
return x > 0 || y > 0 return x > 0 || y > 0
}) })
export function SectionGrid({ /**
section, * The page's widgets as one arrangement.
dashboard, *
columns = DEFAULT_COLUMNS, * Sections used to be drawn as separate grids stacked down the page, each
renderWidget, * starting at its own row zero, so a document written with several of them is
stacked, * read as one grid by pushing each below the one before it. That is what lets
className, * the editor and the panel show the same thing: the editor arranges one grid,
}: { * and it now arranges all of them.
section: SectionDef_Output *
/** Which dashboard this is, so an input widget can name itself. */ * An unarranged document is left alone — every widget still sits at 0,0 there,
dashboard: string * so there is no arrangement to keep apart and the grid auto-flows instead.
columns?: number *
renderWidget?: (widget: WidgetDef) => React.ReactNode * ponytail: a section's own heading stops being drawn with it. No UI ever
/** One column at the viewport's width, for a phone. */ * wrote one, so only a seeded document has any — restoring them means giving
stacked?: boolean * a group a box on the canvas, which is a widget, not a section.
className?: string */
}) { export function flatWidgets(page: PageDef_Output): WidgetDef[] {
const all = widgetsOf(section) const sections = sectionsOf(page)
// Stacked, the arrangement becomes a reading order, so it follows the rows const all = sections.flatMap(widgetsOf)
// the panel shows rather than the order widgets happened to be added in. if (sections.length < 2 || !isPlaced(all)) return all
const widgets = stacked
? [...all].sort((a, b) => { const flat: WidgetDef[] = []
const left = placement(a) let offset = 0
const right = placement(b) for (const section of sections) {
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0) let bottom = 0
}) for (const widget of widgetsOf(section)) {
: all const placed = placement(widget)
return ( const y = Math.max(0, placed.y ?? 0)
<section className="grid gap-3"> bottom = Math.max(bottom, y + Math.max(1, placed.h ?? 2))
{section.title ? ( flat.push(
<h2 className="text-sm font-medium text-muted-foreground"> offset === 0
{section.title} ? widget
</h2> : {
) : null} ...widget,
<div layout: {
className={cn("widget-grid", stacked && "widget-stacked", className)} ...(widget.layout ?? {}),
data-placed={isPlaced(widgets) || undefined} lg: { ...placed, y: y + offset },
style={{ "--widget-cols": columns } as React.CSSProperties} },
> },
{widgets.map((widget) => ( )
<div }
key={widget.id} offset += bottom
style={widgetStyle(widget, columns)} }
className="widget-cell" return flat
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard} />
</WidgetFrame>
)}
</div>
))}
</div>
</section>
)
} }
/**
* One page, drawn as the single grid the panel shows.
*
* A dashboard is one canvas — several dashboards on a panel is what the rail
* is for — so the page's sections are one arrangement rather than a stack of
* headed grids. `SectionDef` stays in the schema, and the editor writes what
* it arranged back into the first section.
*/
export function DashboardView({ export function DashboardView({
dashboard, dashboard,
pageId,
renderWidget, renderWidget,
stacked, stacked,
}: { }: {
dashboard: Dashboard dashboard: Dashboard
pageId?: string
renderWidget?: (widget: WidgetDef) => React.ReactNode renderWidget?: (widget: WidgetDef) => React.ReactNode
/** One column at the viewport's width, for a phone. */ /** One column at the viewport's width, for a phone. */
stacked?: boolean stacked?: boolean
}) { }) {
const pages = pagesOf(dashboard) // ponytail: the first page only. A panel carries several whole dashboards
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] // — that is what the rail is for — so nothing writes a second page.
const page = pagesOf(dashboard)[0]
if (!page) { if (!page) {
return ( return (
@@ -246,10 +268,8 @@ export function DashboardView({
) )
} }
const empty = sectionsOf(page).every( const all = flatWidgets(page)
(section) => widgetsOf(section).length === 0, if (all.length === 0) {
)
if (empty) {
return ( return (
<p <p
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
@@ -260,17 +280,44 @@ export function DashboardView({
) )
} }
// Stacked, the arrangement becomes a reading order, so it follows the rows
// the panel shows rather than the order widgets happened to be added in.
const widgets = stacked
? [...all].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
: all
const columns = columnsOf(dashboard)
return ( return (
<div className="grid gap-6"> <div
{sectionsOf(page).map((section) => ( className={cn("widget-grid", stacked && "widget-stacked")}
<SectionGrid data-placed={isPlaced(widgets) || undefined}
key={section.id} style={
section={section} {
dashboard={dashboard.name} "--widget-cols": columns,
columns={columnsOf(dashboard)} // The row height follows the canvas, so the CSS grid and the
renderWidget={renderWidget} // editor's grid library cannot drift apart.
stacked={stacked} "--row-height": `${rowHeightOf(dashboard)}px`,
/> } as React.CSSProperties
}
>
{widgets.map((widget) => (
<div
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</WidgetFrame>
)}
</div>
))} ))}
</div> </div>
) )
@@ -30,7 +30,9 @@
.widget-grid { .widget-grid {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
grid-auto-rows: 5rem; /* The row height follows the canvas, handed in beside the column count;
5rem is what a 1920x1080 panel at 12 columns derives. */
grid-auto-rows: var(--row-height, 5rem);
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr)); grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
/* Stacked, this is a flex item in an `auto` track, and its automatic minimum /* Stacked, this is a flex item in an `auto` track, and its automatic minimum
size is the widest thing any widget holds — one long message name would size is the widest thing any widget holds — one long message name would
@@ -69,7 +71,7 @@
.widget-grid.widget-stacked .widget-cell { .widget-grid.widget-stacked .widget-cell {
grid-column: auto; grid-column: auto;
grid-row: auto; grid-row: auto;
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem); height: calc(var(--h) * var(--row-height, 5rem) + (var(--h) - 1) * 0.75rem);
} }
/* /*
@@ -122,48 +124,6 @@
} }
} }
/*
* uPlot, routed through the tokens. Its own legend is the hover readout as
* well — the value each line carried at the cursor — so it is styled as chart
* furniture rather than replaced.
*/
.u-legend {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
/* Series labels default to the message name, which has no spaces to break at
— and a table cannot lay out below its min-content width. */
.u-legend th {
overflow-wrap: anywhere;
}
.u-legend .u-marker {
width: 0.5rem;
height: 0.5rem;
border-width: 2px;
border-radius: 9999px;
}
.u-legend .u-value {
font-variant-numeric: tabular-nums;
color: var(--foreground);
}
.u-legend .u-series.u-off {
opacity: 0.4;
}
.u-cursor-x,
.u-cursor-y {
border-color: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
}
.u-select {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* react-grid-layout ships a red placeholder and a black handle glyph. */ /* react-grid-layout ships a red placeholder and a black handle glyph. */
.react-grid-item.react-grid-placeholder { .react-grid-item.react-grid-placeholder {
background: var(--primary); background: var(--primary);