Dashboard-level chart palette
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Failing after 9m49s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m31s
Test Backend / test-backend (push) Failing after 48s
Compose Smoke Test / test-compose (push) Failing after 18s
Playwright Tests / merge-reports (push) Failing after 42s

A dashboard names the data colours its charts draw with: an ordered, distinct
subset of the `--chart-1…5` ramp, stored on the settings channel that already
carries `theme` and `locked`. No backend or client change — `SettingDef.value`
is free-form and a name this build does not wire up is left alone rather than
refused.

Naming none is the whole ramp, which resolves to the identical token per
series as the `--chart-${(index % 5) + 1}` charts drew with before, so every
existing dashboard is unaffected. `palette.check.ts` asserts that equivalence
rather than trusting it.

Distinct slots, not free assignment with repeats: only slot 1 against slot 5
clears 3:1 within the ramp, so two traces on one slot could not be told apart.
Status colour is deliberately outside the palette — a fault is `--destructive`
because of what it means, not because of where it sits.

The provider is mounted by the editor as well as the view, so picking a
palette repaints the charts beside the panel instead of describing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
This commit is contained in:
2026-08-23 08:45:31 +02:00
co-authored by Claude Opus 5
parent 648eb24de2
commit 6336c6d91f
8 changed files with 265 additions and 37 deletions
+50 -4
View File
@@ -20,6 +20,45 @@ import { si } from "@/lib/utils"
*/ */
export const MAX_SERIES = 5 export const MAX_SERIES = 5
/**
* The ramp's slots, as a palette names them.
*
* The whole set one may draw from — five, for the same reason `MAX_SERIES` is
* five, because they are the same five.
*/
export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) =>
String(index + 1),
)
/** What a chart draws with when nothing chose for it: the ramp, in order. */
export const DEFAULT_PALETTE = CHART_SLOTS
/**
* A stored palette, made safe to draw with: a distinct subset, in draw order.
*
* Distinct on purpose, and worth keeping that way. Within the ramp only slot 1
* against slot 5 clears the 3:1 guideline for non-text — 3.28 light, 3.37 dark,
* measured in `BarWidget.tsx` — so two traces on neighbouring slots are already
* close, and two on the *same* slot could not be told apart at all. Free
* assignment with repeats is not an improvement on this; it is the bug.
*
* Anything unrecognised, repeated or empty falls back to the whole ramp, which
* is what every chart drew before a dashboard could name a palette.
*/
export function paletteOf(value: unknown): string[] {
if (!Array.isArray(value)) return DEFAULT_PALETTE
const slots = [...new Set(value.map(String))].filter((slot) =>
CHART_SLOTS.includes(slot),
)
return slots.length > 0 ? slots : DEFAULT_PALETTE
}
/** Which slot of the ramp the *index*th series takes from a palette. */
export function slotFor(index: number, palette: string[] = DEFAULT_PALETTE) {
const slots = palette.length > 0 ? palette : DEFAULT_PALETTE
return slots[index % slots.length]
}
/** Room for the axis ticks; uPlot measures the rest of the box itself. */ /** Room for the axis ticks; uPlot measures the rest of the box itself. */
const PADDING: uPlot.Padding = [10, 12, 0, 0] const PADDING: uPlot.Padding = [10, 12, 0, 0]
@@ -33,7 +72,8 @@ function token(name: string): string {
.trim() .trim()
} }
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`) const seriesColor = (index: number, palette?: string[]) =>
token(`--chart-${slotFor(index, palette)}`)
/** /**
* Tick labels that stay distinct. * Tick labels that stay distinct.
@@ -84,6 +124,7 @@ export function UplotChart({
unit, unit,
yRange, yRange,
yLabel, yLabel,
palette,
smooth = false, smooth = false,
onCursor, onCursor,
onSelect, onSelect,
@@ -102,6 +143,10 @@ export function UplotChart({
yRange?: [number, number] yRange?: [number, number]
/** A named y axis; the unit stays on the ticks. */ /** A named y axis; the unit stays on the ticks. */
yLabel?: string yLabel?: string
/** The ramp slots this chart's lines take, in order — the dashboard's own
* palette. Unset is the whole ramp, which is what a chart outside a
* dashboard (Health, Home) draws with. */
palette?: string[]
/** Draw the lines as a monotone cubic spline rather than straight segments. /** Draw the lines as a monotone cubic spline rather than straight segments.
* Monotone rather than plain cubic on purpose: a spline that overshoots * Monotone rather than plain cubic on purpose: a spline that overshoots
* invents readings between two the sensor actually took. */ * invents readings between two the sensor actually took. */
@@ -126,8 +171,9 @@ export function UplotChart({
const points = plots.reduce((total, plot) => total + plot.length, 0) const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes, // The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data. The unit, the fixed range and the // while a new reading only sets its data. The unit, the fixed range and the
// axis title are part of it — all are baked into the axes at build time. // axis title are part of it — all are baked into the axes at build time
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}` // and so are the line shape and the palette, which the series close over.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}`
// uPlot leaves its axes half-initialised while the scales have no range, and // uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and // a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether. // throws. Waiting for the first reading avoids the state altogether.
@@ -227,7 +273,7 @@ export function UplotChart({
width: 2, width: 2,
// Read at draw time, so a theme toggle is a redraw rather than a // Read at draw time, so a theme toggle is a redraw rather than a
// rebuilt chart. // rebuilt chart.
stroke: () => seriesColor(index), stroke: () => seriesColor(index, palette),
// The cursor readout is what decides how wide the legend gets, so // The cursor readout is what decides how wide the legend gets, so
// it is shortened here; a named unit is short enough to keep. Four // it is shortened here; a named unit is short enough to keep. Four
// figures rather than three: this is the number someone is pointing // figures rather than three: this is the number someone is pointing
@@ -11,6 +11,7 @@ import {
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart" import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore" import { useLiveValue } from "@/components/Flow/liveStore"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries" import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import { usePalette } from "./settings"
import { type Series, useHeaderSlot, type WidgetProps } from "./widgets" import { type Series, useHeaderSlot, type WidgetProps } from "./widgets"
// The five-series ceiling is the chart host's, and the panel reads it here. // The five-series ceiling is the chart host's, and the panel reads it here.
@@ -120,6 +121,9 @@ function LiveChart({ widget }: WidgetProps) {
) || DEFAULT_POINTS, ) || DEFAULT_POINTS,
) )
// The dashboard's own data colours; outside one this is the whole ramp.
const palette = usePalette()
const histories = useQueries({ const histories = useQueries({
queries: names.map((name) => messageHistoryQueryOptions(name)), queries: names.map((name) => messageHistoryQueryOptions(name)),
}) })
@@ -182,6 +186,7 @@ function LiveChart({ widget }: WidgetProps) {
<UplotChart <UplotChart
labels={labels} labels={labels}
plots={plots} plots={plots}
palette={palette}
{...presentation(widget.config as Record<string, unknown>)} {...presentation(widget.config as Record<string, unknown>)}
/> />
) )
@@ -214,6 +219,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
// about less often. // about less often.
const refreshS = refreshFor(range, cfg.refresh_s) const refreshS = refreshFor(range, cfg.refresh_s)
const palette = usePalette()
const publish = usePublishMessage() const publish = usePublishMessage()
const live = useLiveValue(message || undefined) const live = useLiveValue(message || undefined)
const [answer, setAnswer] = useState<SeriesPayload | null>(null) const [answer, setAnswer] = useState<SeriesPayload | null>(null)
@@ -285,6 +291,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
(line.points ?? []).map(([ts, value]) => ({ ts, value })), (line.points ?? []).map(([ts, value]) => ({ ts, value })),
)} )}
empty="Waiting for an answer." empty="Waiting for an answer."
palette={palette}
{...presentation(cfg)} {...presentation(cfg)}
/> />
) )
@@ -78,7 +78,7 @@ import {
usePublishDashboard, usePublishDashboard,
useSaveDashboard, useSaveDashboard,
} from "./queries" } from "./queries"
import { useDashboardTheme } from "./settings" import { PaletteProvider, useDashboardTheme } from "./settings"
import { import {
WIDGET_LABELS, WIDGET_LABELS,
WIDGET_SIZES, WIDGET_SIZES,
@@ -528,7 +528,7 @@ export function DashboardEditor({
} }
data-testid="dashboard-canvas" data-testid="dashboard-canvas"
> >
{body} <PaletteProvider dashboard={draft}>{body}</PaletteProvider>
</div> </div>
<div <div
@@ -9,7 +9,7 @@ import type {
} from "@/client" } from "@/client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import "./dashboard.css" import "./dashboard.css"
import { LockedProvider, useDashboardTheme } from "./settings" import { LockedProvider, PaletteProvider, useDashboardTheme } from "./settings"
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets" import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
export type Dashboard = DashboardDef_Output export type Dashboard = DashboardDef_Output
@@ -304,34 +304,36 @@ export function DashboardView({
return ( return (
<LockedProvider dashboard={dashboard}> <LockedProvider dashboard={dashboard}>
<div <PaletteProvider dashboard={dashboard}>
className={cn("widget-grid", stacked && "widget-stacked")} <div
data-placed={isPlaced(widgets) || undefined} className={cn("widget-grid", stacked && "widget-stacked")}
style={ data-placed={isPlaced(widgets) || undefined}
{ style={
"--widget-cols": columns, {
// The row height follows the canvas, so the CSS grid and the "--widget-cols": columns,
// editor's grid library cannot drift apart. // The row height follows the canvas, so the CSS grid and the
"--row-height": `${rowHeightOf(dashboard)}px`, // editor's grid library cannot drift apart.
} as React.CSSProperties "--row-height": `${rowHeightOf(dashboard)}px`,
} } as React.CSSProperties
> }
{widgets.map((widget) => ( >
<div {widgets.map((widget) => (
key={widget.id} <div
style={widgetStyle(widget, columns)} key={widget.id}
className="widget-cell" style={widgetStyle(widget, columns)}
> className="widget-cell"
{renderWidget ? ( >
renderWidget(widget) {renderWidget ? (
) : ( renderWidget(widget)
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}> ) : (
<WidgetBody widget={widget} dashboard={dashboard.name} /> <WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
</WidgetFrame> <WidgetBody widget={widget} dashboard={dashboard.name} />
)} </WidgetFrame>
</div> )}
))} </div>
</div> ))}
</div>
</PaletteProvider>
</LockedProvider> </LockedProvider>
) )
} }
@@ -0,0 +1,81 @@
/**
* The dashboard palette, checked.
*
* ponytail: a script rather than a suite, like `color.check.ts` beside it —
* the frontend's only runner is Playwright and picking a ramp slot does not
* need a browser:
*
* cd frontend && bun run src/components/Dashboard/palette.check.ts
*
* The first block is the one that matters. A palette changes how *every*
* existing dashboard is drawn, and the whole safety argument is that a
* dashboard which names none is drawn exactly as it was before palettes
* existed — `--chart-${(index % 5) + 1}`, the literal expression `seriesColor`
* used. That equivalence is asserted here rather than trusted.
*/
import assert from "node:assert/strict"
import {
CHART_SLOTS,
DEFAULT_PALETTE,
paletteOf,
slotFor,
} from "@/components/Common/UplotChart"
/** What a chart drew before a dashboard could name a palette. */
const before = (index: number) => String((index % 5) + 1)
// A document with no palette, and every shape a broken one can arrive in:
// all of them fall back to the ramp, and the ramp is the old expression.
for (const stored of [undefined, null, "", [], ["9", "nonsense"], { 0: "1" }]) {
const palette = paletteOf(stored)
assert.deepEqual(
palette,
DEFAULT_PALETTE,
`${JSON.stringify(stored)} is no palette`,
)
for (let index = 0; index < 23; index++) {
assert.equal(
slotFor(index, palette),
before(index),
`series ${index} of an unset palette draws as it always did`,
)
}
}
// The same, for a chart that was handed nothing at all — Health and Home draw
// through the very same component.
for (let index = 0; index < 23; index++) {
assert.equal(
slotFor(index),
before(index),
`series ${index} outside a dashboard`,
)
}
// A distinct subset in draw order: kept as picked, and cycled through.
assert.deepEqual(
paletteOf(["3", "1"]),
["3", "1"],
"the order picked is the order kept",
)
assert.deepEqual(
[0, 1, 2, 3, 4].map((index) => slotFor(index, ["3", "1"])),
["3", "1", "3", "1", "3"],
"a chart with more lines than colours starts over",
)
// Repeats are dropped rather than drawn: two lines on one slot could not be
// told apart, which is the whole reason the picker offers each slot once.
assert.deepEqual(paletteOf(["2", "2", "4"]), ["2", "4"], "a repeat is dropped")
assert.deepEqual(paletteOf([1, 2]), ["1", "2"], "numbers name slots too")
assert.deepEqual(paletteOf([...CHART_SLOTS].reverse()), [
"5",
"4",
"3",
"2",
"1",
])
console.log("palette: ok")
@@ -4,6 +4,7 @@ import { useState } from "react"
import type { MessageInfo, SettingDef, WidgetDef } from "@/client" import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart"
import { import {
PANEL_SECTION, PANEL_SECTION,
PanelTitle, PanelTitle,
@@ -1071,6 +1072,8 @@ export function DashboardPanel({
const canvas = canvasOf(dashboard) const canvas = canvasOf(dashboard)
const theme = settingOf(dashboard, "theme") const theme = settingOf(dashboard, "theme")
const locked = settingOf(dashboard, "locked") const locked = settingOf(dashboard, "locked")
const paletteSetting = settingOf(dashboard, "palette")
const palette = paletteOf(paletteSetting.value)
/** Settings are a map, so one of them changing rewrites the whole of it. */ /** Settings are a map, so one of them changing rewrites the whole of it. */
const setSetting = (name: SettingName, setting: SettingDef) => const setSetting = (name: SettingName, setting: SettingDef) =>
@@ -1225,6 +1228,57 @@ export function DashboardPanel({
</p> </p>
</div> </div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Palette</span>
<div
className="flex flex-wrap items-center gap-2"
data-testid="dashboard-palette"
>
{CHART_SLOTS.map((slot) => {
const picked = palette.includes(slot)
return (
<button
key={slot}
type="button"
aria-pressed={picked}
aria-label={`Colour ${slot}`}
// The last one cannot go. An empty palette falls back to
// the whole ramp, so the row would then contradict what
// the charts beside it are drawing.
disabled={picked && palette.length === 1}
onClick={() =>
setSetting("palette", {
...paletteSetting,
value: picked
? palette.filter((other) => other !== slot)
: [...palette, slot],
})
}
className={cn(
"size-11 rounded-full transition-shadow md:size-8",
picked &&
"ring-2 ring-ring ring-offset-2 ring-offset-card",
)}
style={{
// A token alpha rather than `opacity`, which would fade
// the ring with the swatch.
background: picked
? `var(--chart-${slot})`
: `color-mix(in srgb, var(--chart-${slot}) 30%, transparent)`,
}}
/>
)
})}
</div>
<p className="text-sm text-muted-foreground">
The colours this dashboard's charts draw with, taken in the order
you pick them — a chart with more lines than colours starts over
at the first. Each is offered once: two lines sharing a colour
could not be told apart, and neighbouring ones are close enough
already.
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<span className={PANEL_SECTION}>Lock</span> <span className={PANEL_SECTION}>Lock</span>
<div className="flex items-center justify-between gap-2 text-sm"> <div className="flex items-center justify-between gap-2 text-sm">
+35 -1
View File
@@ -1,6 +1,7 @@
import { createContext, useContext } from "react" import { createContext, useContext } from "react"
import type { DashboardDef_Output, SettingDef } from "@/client" import type { DashboardDef_Output, SettingDef } from "@/client"
import { DEFAULT_PALETTE, paletteOf } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore" import { useLiveValue } from "@/components/Flow/liveStore"
/** /**
@@ -35,7 +36,7 @@ export const SETTING_DTYPES: Record<string, string> = {
} }
/** The settings this build actually wires up. */ /** The settings this build actually wires up. */
export type SettingName = "theme" | "locked" export type SettingName = "theme" | "locked" | "palette"
/** What `theme` may be set to. `system` follows whatever the device says. */ /** What `theme` may be set to. `system` follows whatever the device says. */
export const THEME_CHOICES = [ export const THEME_CHOICES = [
@@ -99,6 +100,39 @@ export function useDashboardLocked(
return useSetting(dashboard, "locked") === true return useSetting(dashboard, "locked") === true
} }
const PaletteContext = createContext(DEFAULT_PALETTE)
/**
* The data colours everything drawn under it uses.
*
* Mounted by the view *and* the editor: an editor showing the default ramp
* while the panel beside it showed the dashboard's own palette would be a
* preview that lies.
*
* Data colour only. A fault stays `--destructive` and a condition stays its
* `ICON_COLORS` entry, because those name a state rather than tell one series
* from another — a palette they followed could paint a failure the same blue
* as a reading. Only the chart reads this today; a second consumer costs one
* `usePalette()`.
*/
export function PaletteProvider({
dashboard,
children,
}: {
dashboard: DashboardDef_Output | undefined
children: React.ReactNode
}) {
const palette = paletteOf(useSetting(dashboard, "palette"))
return (
<PaletteContext.Provider value={palette}>
{children}
</PaletteContext.Provider>
)
}
/** The ramp slots the dashboard around this widget draws its data with. */
export const usePalette = () => useContext(PaletteContext)
const LockedContext = createContext(false) const LockedContext = createContext(false)
/** /**
+5 -1
View File
@@ -1234,7 +1234,11 @@ def seed_dashboard(api: Api) -> None:
"value": "system", "value": "system",
"message": msg(HOUSE, "panel_theme"), "message": msg(HOUSE, "panel_theme"),
"dtype": "str", "dtype": "str",
} },
# The ends and the middle of the ramp rather than its first
# three steps: the Power chart draws three lines, and slots
# 1/3/5 stand further apart than 1/2/3 do.
"palette": {"value": ["1", "3", "5"]},
}, },
"pages": PAGES, "pages": PAGES,
}, },