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:
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user