Spread a chart's default colours across the ramp

A chart that named no palette took the ramp in order, so three lines drew
slots 1, 2 and 3 — adjacent steps of a ramp that carries identity by lightness
alone, which at a 2px stroke read as close to one picture. Each count now
takes the slots furthest apart that the ramp allows: 1 -> 1, 2 -> 1/5,
3 -> 1/3/5, 4 -> 1/2/4/5. Five lines are unchanged.

Moving a default is only safe because the mechanism around it is inert: a
palette a dashboard wrote down is still drawn exactly as written, so no stored
document means anything different than it did. `palette.check.ts` now pins the
spread table and says which of the two properties is permanent.

An unnamed palette is empty rather than the whole ramp, so the settings panel
shows nothing picked when a dashboard is on automatic — and deselecting the
last colour is now the way back to it.

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 10:53:28 +02:00
co-authored by Claude Opus 5
parent 98bf8fb7b0
commit d5fc3fa8e8
4 changed files with 126 additions and 65 deletions
@@ -7,63 +7,76 @@
*
* 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.
* Two properties, and it is worth being clear which is which.
*
* The one that must never move is **inertness**: a palette a dashboard wrote
* down is drawn exactly as written, so shipping this mechanism could not
* change the meaning of a single stored document. That is what made the
* default safe to move afterwards.
*
* The one that is free to move is the **default** itself — where a chart that
* named no palette lands. It started as the ramp in order, which gave three
* lines the three adjacent steps 1, 2 and 3; it is now spread. That is a
* design decision, pinned here so it changes on purpose rather than by
* accident, and a five-line chart is unchanged either way.
*/
import assert from "node:assert/strict"
import {
CHART_SLOTS,
DEFAULT_PALETTE,
NO_PALETTE,
paletteOf,
slotFor,
slotsFor,
} from "@/components/Common/UplotChart"
/** What a chart drew before a dashboard could name a palette. */
const before = (index: number) => String((index % 5) + 1)
// --- the default: spread, not the first n steps ---------------------------
// 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)
const SPREAD: Record<number, string[]> = {
1: ["1"],
2: ["1", "5"],
3: ["1", "3", "5"],
4: ["1", "2", "4", "5"],
5: ["1", "2", "3", "4", "5"],
}
for (const [lines, want] of Object.entries(SPREAD)) {
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`,
slotsFor(Number(lines)),
want,
`${lines} lines with no palette named`,
)
}
// A distinct subset in draw order: kept as picked, and cycled through.
// Five lines take the whole ramp in order, which is what they always took.
assert.deepEqual(
slotsFor(CHART_SLOTS.length),
[...CHART_SLOTS],
"a five-line chart is drawn exactly as it was before palettes existed",
)
// A chart is never left without a colour, whatever it is handed.
for (const lines of [0, -3, 1.5, 9, Number.NaN]) {
const slots = slotsFor(lines)
assert.ok(slots?.length > 0, `${lines} lines still gets a colour`)
}
// --- inertness: a named palette is drawn as written -----------------------
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",
slotsFor(2, ["3", "1"]),
["3", "1"],
"a named palette is never re-spread",
)
assert.deepEqual(
slotsFor(5, ["3", "1"]),
["3", "1"],
"and it is not widened to fit more lines either — the chart cycles",
)
// Repeats are dropped rather than drawn: two lines on one slot could not be
@@ -78,4 +91,21 @@ assert.deepEqual(paletteOf([...CHART_SLOTS].reverse()), [
"1",
])
// Naming none, and every shape a broken one arrives in, all mean the same
// thing: no palette, so the chart spreads itself.
for (const stored of [undefined, null, "", [], ["9", "nonsense"], { 0: "1" }]) {
assert.equal(
paletteOf(stored),
NO_PALETTE,
`${JSON.stringify(stored)} names no palette`,
)
for (let lines = 1; lines <= CHART_SLOTS.length; lines++) {
assert.deepEqual(
slotsFor(lines, paletteOf(stored)),
SPREAD[lines],
`${lines} lines still spread`,
)
}
}
console.log("palette: ok")
+4 -7
View File
@@ -1242,10 +1242,6 @@ export function DashboardPanel({
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,
@@ -1273,9 +1269,10 @@ export function DashboardPanel({
<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.
at the first. Pick none and each chart spreads itself across the
range by how many lines it has, which is usually what you want.
Each colour is offered once: two lines sharing one could not be
told apart, and neighbouring ones are close enough already.
</p>
</div>
+10 -6
View File
@@ -1,7 +1,7 @@
import { createContext, useContext } from "react"
import type { DashboardDef_Output, SettingDef } from "@/client"
import { DEFAULT_PALETTE, paletteOf } from "@/components/Common/UplotChart"
import { NO_PALETTE, paletteOf } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
/**
@@ -100,14 +100,18 @@ export function useDashboardLocked(
return useSetting(dashboard, "locked") === true
}
const PaletteContext = createContext(DEFAULT_PALETTE)
const PaletteContext = createContext(NO_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.
* Empty when the dashboard names none, which is not the same as "the ramp":
* a chart with no palette spreads itself across the ramp by how many lines it
* draws (`slotsFor`), and only a named palette overrides that.
*
* Mounted by the view *and* the editor: an editor drawing the automatic
* spread 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
@@ -130,7 +134,7 @@ export function PaletteProvider({
)
}
/** The ramp slots the dashboard around this widget draws its data with. */
/** The ramp slots the dashboard around this widget named, or none. */
export const usePalette = () => useContext(PaletteContext)
const LockedContext = createContext(false)