From 2fcaf96a83c975ad553de34fbce9370688b26c2a Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 23 Aug 2026 08:45:31 +0200 Subject: [PATCH] Dashboard-level chart palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN --- frontend/src/components/Common/UplotChart.tsx | 54 ++++++++++++- .../src/components/Dashboard/ChartWidget.tsx | 7 ++ .../components/Dashboard/DashboardEditor.tsx | 4 +- .../components/Dashboard/DashboardView.tsx | 60 +++++++------- .../src/components/Dashboard/palette.check.ts | 81 +++++++++++++++++++ frontend/src/components/Dashboard/panels.tsx | 54 +++++++++++++ .../src/components/Dashboard/settings.tsx | 36 ++++++++- 7 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/Dashboard/palette.check.ts diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 3ee65e0..4b70a94 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -20,6 +20,45 @@ import { si } from "@/lib/utils" */ 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. */ const PADDING: uPlot.Padding = [10, 12, 0, 0] @@ -33,7 +72,8 @@ function token(name: string): string { .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. @@ -84,6 +124,7 @@ export function UplotChart({ unit, yRange, yLabel, + palette, smooth = false, onCursor, onSelect, @@ -102,6 +143,10 @@ export function UplotChart({ yRange?: [number, number] /** A named y axis; the unit stays on the ticks. */ 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. * Monotone rather than plain cubic on purpose: a spline that overshoots * 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) // 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 - // axis title are part of it — all are baked into the axes at build time. - const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}` + // axis title are part of it — all are baked into the axes at build time — + // 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 // a resize in that window (a card still settling, say) draws them anyway and // throws. Waiting for the first reading avoids the state altogether. @@ -227,7 +273,7 @@ export function UplotChart({ width: 2, // Read at draw time, so a theme toggle is a redraw rather than a // rebuilt chart. - stroke: () => seriesColor(index), + stroke: () => seriesColor(index, palette), // 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 // figures rather than three: this is the number someone is pointing diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index 38928f4..c434886 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -11,6 +11,7 @@ import { import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart" import { useLiveValue } from "@/components/Flow/liveStore" import { messageHistoryQueryOptions, usePublishMessage } from "./queries" +import { usePalette } from "./settings" import { type Series, useHeaderSlot, type WidgetProps } from "./widgets" // 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, ) + // The dashboard's own data colours; outside one this is the whole ramp. + const palette = usePalette() + const histories = useQueries({ queries: names.map((name) => messageHistoryQueryOptions(name)), }) @@ -182,6 +186,7 @@ function LiveChart({ widget }: WidgetProps) { )} /> ) @@ -214,6 +219,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) { // about less often. const refreshS = refreshFor(range, cfg.refresh_s) + const palette = usePalette() const publish = usePublishMessage() const live = useLiveValue(message || undefined) const [answer, setAnswer] = useState(null) @@ -285,6 +291,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) { (line.points ?? []).map(([ts, value]) => ({ ts, value })), )} empty="Waiting for an answer." + palette={palette} {...presentation(cfg)} /> ) diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 1d09e4f..4ed787d 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -78,7 +78,7 @@ import { usePublishDashboard, useSaveDashboard, } from "./queries" -import { useDashboardTheme } from "./settings" +import { PaletteProvider, useDashboardTheme } from "./settings" import { WIDGET_LABELS, WIDGET_SIZES, @@ -528,7 +528,7 @@ export function DashboardEditor({ } data-testid="dashboard-canvas" > - {body} + {body}
-
- {widgets.map((widget) => ( -
- {renderWidget ? ( - renderWidget(widget) - ) : ( - - - - )} -
- ))} -
+ +
+ {widgets.map((widget) => ( +
+ {renderWidget ? ( + renderWidget(widget) + ) : ( + + + + )} +
+ ))} +
+
) } diff --git a/frontend/src/components/Dashboard/palette.check.ts b/frontend/src/components/Dashboard/palette.check.ts new file mode 100644 index 0000000..0d54a77 --- /dev/null +++ b/frontend/src/components/Dashboard/palette.check.ts @@ -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") diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 96ed5ef..7e97279 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import type { MessageInfo, SettingDef, WidgetDef } from "@/client" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" +import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart" import { PANEL_SECTION, PanelTitle, @@ -1071,6 +1072,8 @@ export function DashboardPanel({ const canvas = canvasOf(dashboard) const theme = settingOf(dashboard, "theme") 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. */ const setSetting = (name: SettingName, setting: SettingDef) => @@ -1225,6 +1228,57 @@ export function DashboardPanel({

+
+ Palette +
+ {CHART_SLOTS.map((slot) => { + const picked = palette.includes(slot) + return ( +
+

+ 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. +

+
+
Lock
diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx index 23b1911..e13b12f 100644 --- a/frontend/src/components/Dashboard/settings.tsx +++ b/frontend/src/components/Dashboard/settings.tsx @@ -1,6 +1,7 @@ import { createContext, useContext } from "react" import type { DashboardDef_Output, SettingDef } from "@/client" +import { DEFAULT_PALETTE, paletteOf } from "@/components/Common/UplotChart" import { useLiveValue } from "@/components/Flow/liveStore" /** @@ -35,7 +36,7 @@ export const SETTING_DTYPES: Record = { } /** 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. */ export const THEME_CHOICES = [ @@ -99,6 +100,39 @@ export function useDashboardLocked( 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 ( + + {children} + + ) +} + +/** The ramp slots the dashboard around this widget draws its data with. */ +export const usePalette = () => useContext(PaletteContext) + const LockedContext = createContext(false) /**