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:
@@ -2,6 +2,11 @@ import { useEffect, useLayoutEffect, useRef } from "react"
|
||||
import uPlot from "uplot"
|
||||
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 { useTheme } from "@/components/theme-provider"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
@@ -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
|
||||
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",
|
||||
)}
|
||||
style={
|
||||
fits
|
||||
? { right: `${(1 - fraction) * 100}%` }
|
||||
: { left: `${fraction * 100}%` }
|
||||
}
|
||||
style={{
|
||||
left: `${fraction * 100}%`,
|
||||
transform: fits ? "translateX(-100%)" : "translateX(0)",
|
||||
}}
|
||||
>
|
||||
{write(value)}
|
||||
</span>
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -60,11 +59,12 @@ import {
|
||||
columnsOf,
|
||||
type Dashboard,
|
||||
DashboardView,
|
||||
flatWidgets,
|
||||
GRID_GAP,
|
||||
isPlaced,
|
||||
pagesOf,
|
||||
placement,
|
||||
ROW_HEIGHT,
|
||||
rowHeightOf,
|
||||
rowsOf,
|
||||
sectionsOf,
|
||||
widgetsOf,
|
||||
@@ -208,9 +208,6 @@ export function DashboardEditor({
|
||||
const [draft, setDraft] = useState<Dashboard>(dashboard)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
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
|
||||
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
||||
const stacked = useIsMobile()
|
||||
@@ -283,26 +280,32 @@ export function DashboardEditor({
|
||||
// a save in flight is still "no unpublished changes" until it lands.
|
||||
const hasDraft = Boolean(dashboard.has_draft)
|
||||
const columns = columnsOf(draft)
|
||||
const pages = pagesOf(draft)
|
||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||
const section = page ? sectionsOf(page)[0] : undefined
|
||||
const widgets = section ? widgetsOf(section) : []
|
||||
const page = pagesOf(draft)[0]
|
||||
// Every widget the panel shows, not just the first section's — a document
|
||||
// written with several sections is one arrangement here, as it is there.
|
||||
const widgets = page ? flatWidgets(page) : []
|
||||
const canvas = canvasOf(draft)
|
||||
|
||||
const updateWidgets = (next: WidgetDef[]) => {
|
||||
if (!page || !section) return
|
||||
if (!page) return
|
||||
commit({
|
||||
...draft,
|
||||
// Only this page is rewritten: a document carrying pages the editor
|
||||
// does not show round-trips them untouched.
|
||||
pages: pagesOf(draft).map((candidate) =>
|
||||
candidate.id !== page.id
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
sections: sectionsOf(candidate).map((existing) =>
|
||||
existing.id !== section.id
|
||||
? existing
|
||||
: { ...existing, widgets: next },
|
||||
),
|
||||
// One canvas, one grid: what several sections were read as is
|
||||
// written back as one, which is also what keeps their row
|
||||
// offsets from being applied a second time on the next read.
|
||||
sections: [
|
||||
{
|
||||
...(sectionsOf(candidate)[0] ?? { id: "main" }),
|
||||
widgets: next,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
})
|
||||
@@ -444,7 +447,6 @@ export function DashboardEditor({
|
||||
<div className="h-full overflow-y-auto">
|
||||
<DashboardView
|
||||
dashboard={draft}
|
||||
pageId={page.id}
|
||||
stacked
|
||||
renderWidget={edit ? pickable : undefined}
|
||||
/>
|
||||
@@ -453,7 +455,7 @@ export function DashboardEditor({
|
||||
<CanvasSurface dashboard={draft} dots={edit}>
|
||||
{(scale) =>
|
||||
!edit ? (
|
||||
<DashboardView dashboard={draft} pageId={page.id} />
|
||||
<DashboardView dashboard={draft} />
|
||||
) : (
|
||||
<GridLayout
|
||||
width={canvas.width}
|
||||
@@ -462,7 +464,7 @@ export function DashboardEditor({
|
||||
positionStrategy={scaledStrategy(scale)}
|
||||
gridConfig={{
|
||||
cols: columns,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
rowHeight: rowHeightOf(draft),
|
||||
margin: [GRID_GAP, GRID_GAP],
|
||||
containerPadding: [0, 0],
|
||||
// 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">
|
||||
{draft.title || draft.name}
|
||||
</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>
|
||||
|
||||
<motion.div
|
||||
|
||||
@@ -31,15 +31,9 @@ export const CANVAS_PRESETS = [
|
||||
{ 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. */
|
||||
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
|
||||
* 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,
|
||||
})
|
||||
|
||||
/** 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. */
|
||||
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
|
||||
@@ -116,7 +144,7 @@ export function CanvasSurface({
|
||||
// One dot per cell corner. The column pitch the grid snaps to is
|
||||
// (width + gap) / columns; a row is one row plus the gap.
|
||||
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`,
|
||||
"--dot-y": `${ROW_PITCH}px`,
|
||||
"--dot-y": `${rowPitchOf(dashboard)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -164,24 +192,94 @@ export const isPlaced = (widgets: WidgetDef[]) =>
|
||||
return x > 0 || y > 0
|
||||
})
|
||||
|
||||
export function SectionGrid({
|
||||
section,
|
||||
/**
|
||||
* The page's widgets as one arrangement.
|
||||
*
|
||||
* Sections used to be drawn as separate grids stacked down the page, each
|
||||
* starting at its own row zero, so a document written with several of them is
|
||||
* read as one grid by pushing each below the one before it. That is what lets
|
||||
* the editor and the panel show the same thing: the editor arranges one grid,
|
||||
* and it now arranges all of them.
|
||||
*
|
||||
* An unarranged document is left alone — every widget still sits at 0,0 there,
|
||||
* so there is no arrangement to keep apart and the grid auto-flows instead.
|
||||
*
|
||||
* ponytail: a section's own heading stops being drawn with it. No UI ever
|
||||
* wrote one, so only a seeded document has any — restoring them means giving
|
||||
* a group a box on the canvas, which is a widget, not a section.
|
||||
*/
|
||||
export function flatWidgets(page: PageDef_Output): WidgetDef[] {
|
||||
const sections = sectionsOf(page)
|
||||
const all = sections.flatMap(widgetsOf)
|
||||
if (sections.length < 2 || !isPlaced(all)) return all
|
||||
|
||||
const flat: WidgetDef[] = []
|
||||
let offset = 0
|
||||
for (const section of sections) {
|
||||
let bottom = 0
|
||||
for (const widget of widgetsOf(section)) {
|
||||
const placed = placement(widget)
|
||||
const y = Math.max(0, placed.y ?? 0)
|
||||
bottom = Math.max(bottom, y + Math.max(1, placed.h ?? 2))
|
||||
flat.push(
|
||||
offset === 0
|
||||
? widget
|
||||
: {
|
||||
...widget,
|
||||
layout: {
|
||||
...(widget.layout ?? {}),
|
||||
lg: { ...placed, y: y + offset },
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
offset += bottom
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
dashboard,
|
||||
columns = DEFAULT_COLUMNS,
|
||||
renderWidget,
|
||||
stacked,
|
||||
className,
|
||||
}: {
|
||||
section: SectionDef_Output
|
||||
/** Which dashboard this is, so an input widget can name itself. */
|
||||
dashboard: string
|
||||
columns?: number
|
||||
dashboard: Dashboard
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
/** One column at the viewport's width, for a phone. */
|
||||
stacked?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const all = widgetsOf(section)
|
||||
// ponytail: the first page only. A panel carries several whole dashboards
|
||||
// — that is what the rail is for — so nothing writes a second page.
|
||||
const page = pagesOf(dashboard)[0]
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This dashboard has no pages yet.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const all = flatWidgets(page)
|
||||
if (all.length === 0) {
|
||||
return (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="dashboard-empty"
|
||||
>
|
||||
Nothing on this page yet. Edit it to add a widget.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -191,17 +289,20 @@ export function SectionGrid({
|
||||
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
|
||||
})
|
||||
: all
|
||||
const columns = columnsOf(dashboard)
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
{section.title ? (
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{section.title}
|
||||
</h2>
|
||||
) : null}
|
||||
<div
|
||||
className={cn("widget-grid", stacked && "widget-stacked", className)}
|
||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={{ "--widget-cols": columns } as React.CSSProperties}
|
||||
style={
|
||||
{
|
||||
"--widget-cols": columns,
|
||||
// The row height follows the canvas, so the CSS grid and the
|
||||
// editor's grid library cannot drift apart.
|
||||
"--row-height": `${rowHeightOf(dashboard)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
@@ -213,65 +314,11 @@ export function SectionGrid({
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||
<WidgetBody widget={widget} dashboard={dashboard} />
|
||||
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardView({
|
||||
dashboard,
|
||||
pageId,
|
||||
renderWidget,
|
||||
stacked,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
pageId?: string
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
/** One column at the viewport's width, for a phone. */
|
||||
stacked?: boolean
|
||||
}) {
|
||||
const pages = pagesOf(dashboard)
|
||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This dashboard has no pages yet.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const empty = sectionsOf(page).every(
|
||||
(section) => widgetsOf(section).length === 0,
|
||||
)
|
||||
if (empty) {
|
||||
return (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="dashboard-empty"
|
||||
>
|
||||
Nothing on this page yet. Edit it to add a widget.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{sectionsOf(page).map((section) => (
|
||||
<SectionGrid
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboard={dashboard.name}
|
||||
columns={columnsOf(dashboard)}
|
||||
renderWidget={renderWidget}
|
||||
stacked={stacked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
.widget-grid {
|
||||
display: grid;
|
||||
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));
|
||||
/* 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
|
||||
@@ -69,7 +71,7 @@
|
||||
.widget-grid.widget-stacked .widget-cell {
|
||||
grid-column: 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-item.react-grid-placeholder {
|
||||
background: var(--primary);
|
||||
|
||||
Reference in New Issue
Block a user