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
@@ -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">