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 ? (
-
-
- )
+/**
+ * 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 (
)
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);