From eb2e6bf6d203a4b68a1fcfde31ca5e63d5ddc896 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 12:22:37 +0200 Subject: [PATCH] Edit the whole dashboard, and let the grid follow its canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu --- frontend/src/components/Common/UplotChart.tsx | 5 + frontend/src/components/Common/uplot.css | 48 ++++ .../src/components/Dashboard/BarWidget.tsx | 15 +- .../components/Dashboard/DashboardEditor.tsx | 49 ++-- .../components/Dashboard/DashboardView.tsx | 213 +++++++++++------- .../src/components/Dashboard/dashboard.css | 48 +--- 6 files changed, 216 insertions(+), 162 deletions(-) create mode 100644 frontend/src/components/Common/uplot.css diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 5b914e4..c95b034 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -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" diff --git a/frontend/src/components/Common/uplot.css b/frontend/src/components/Common/uplot.css new file mode 100644 index 0000000..88b4356 --- /dev/null +++ b/frontend/src/components/Common/uplot.css @@ -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); +} diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx index 56c9469..b2e12d6 100644 --- a/frontend/src/components/Dashboard/BarWidget.tsx +++ b/frontend/src/components/Dashboard/BarWidget.tsx @@ -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. */} {write(value)} diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 8775fc3..646ae31 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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) const [selected, setSelected] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) - const [pageId, setPageId] = useState( - () => 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({
@@ -453,7 +455,7 @@ export function DashboardEditor({ {(scale) => !edit ? ( - + ) : ( {draft.title || draft.name} - {pages.length > 1 ? ( - - - {pages.map((candidate) => ( - - {candidate.title || candidate.id} - - ))} - - - ) : null} ({ 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,79 +192,73 @@ export const isPlaced = (widgets: WidgetDef[]) => return x > 0 || y > 0 }) -export function SectionGrid({ - section, - 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 - renderWidget?: (widget: WidgetDef) => React.ReactNode - /** One column at the viewport's width, for a phone. */ - stacked?: boolean - className?: string -}) { - const all = widgetsOf(section) - // 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 - return ( -
- {section.title ? ( -

- {section.title} -

- ) : null} -
- {widgets.map((widget) => ( -
- {renderWidget ? ( - renderWidget(widget) - ) : ( - - - - )} -
- ))} -
-
- ) +/** + * 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, - 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] + // 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 ( @@ -246,10 +268,8 @@ export function DashboardView({ ) } - const empty = sectionsOf(page).every( - (section) => widgetsOf(section).length === 0, - ) - if (empty) { + const all = flatWidgets(page) + if (all.length === 0) { return (

{ + 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 ( -

- {sectionsOf(page).map((section) => ( - +
+ {widgets.map((widget) => ( +
+ {renderWidget ? ( + renderWidget(widget) + ) : ( + + + + )} +
))}
) diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index 422c65e..376973a 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -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);