Dashboard-level chart palette

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 148d50f2bd
commit 2fcaf96a83
7 changed files with 260 additions and 36 deletions
@@ -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) {
<UplotChart
labels={labels}
plots={plots}
palette={palette}
{...presentation(widget.config as Record<string, unknown>)}
/>
)
@@ -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<SeriesPayload | null>(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)}
/>
)
@@ -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}
<PaletteProvider dashboard={draft}>{body}</PaletteProvider>
</div>
<div
@@ -9,7 +9,7 @@ import type {
} from "@/client"
import { cn } from "@/lib/utils"
import "./dashboard.css"
import { LockedProvider, useDashboardTheme } from "./settings"
import { LockedProvider, PaletteProvider, useDashboardTheme } from "./settings"
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
export type Dashboard = DashboardDef_Output
@@ -304,34 +304,36 @@ export function DashboardView({
return (
<LockedProvider dashboard={dashboard}>
<div
className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined}
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
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</WidgetFrame>
)}
</div>
))}
</div>
<PaletteProvider dashboard={dashboard}>
<div
className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined}
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
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</WidgetFrame>
)}
</div>
))}
</div>
</PaletteProvider>
</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 { 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({
</p>
</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">
<span className={PANEL_SECTION}>Lock</span>
<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 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<string, string> = {
}
/** 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 (
<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)
/**