Draw charts from one categorical palette, --chart-1…8

The one-hue ramp told lines apart by lightness alone. --chart-1 is now
--primary and slots 2-8 are distinct hues (orange, violet, green, orchid,
ochre, teal, magenta) that clear 3:1 on --card and stay apart under
protanopia and deuteranopia; red and sage are left out as the status
colours. A chart takes the slots in order instead of spreading a ramp,
and a dashboard's own palette continues after its primary and listed
colours with slots 2-8 rather than tints of its scheme. Line charts stay
at five series. Tokens mirror index's, as design-check requires.
This commit is contained in:
2026-10-06 07:05:09 +02:00
parent 2c605b2c13
commit 409a90db28
12 changed files with 151 additions and 149 deletions
+5 -5
View File
@@ -71,9 +71,9 @@ takes a `bool`, a gauge takes a number, an agenda takes a `list`, a
notification takes a `record`. Bind one wrong and the editor says so rather
than drawing nothing. The same table is enforced on the server.
Charts are the exception worth knowing: because identity across five series is
carried by lightness alone, a chart with more than one series always draws a
legend.
Charts draw their series in one fixed order of colours: the primary first, then
hues picked to stay apart, colour blindness included. A chart with more than
one series still always draws a legend, so no line is told by colour alone.
## Two kinds of chart
@@ -200,9 +200,9 @@ A palette is an ordered list of colours, and **position is the role**:
| 1 | the ground the dashboard sits on |
| 2 | the surface a widget is |
| 3 | the primary — fills, active controls, the first chart line |
| 4 | the accent — the second chart line |
| 4 | the accent |
| 5 | text |
| 6+ | further chart colours |
| 6+ | the next chart lines, before the app's own chart colours |
Paste a [coolors.co](https://coolors.co) link (or a list of hex colours) and the
whole dashboard is recoloured: every widget, the rail and the charts. Leave the
+13 -36
View File
@@ -15,52 +15,31 @@ import { si } from "@/lib/utils"
/**
* How many lines one chart carries.
*
* The bound is the palette's: `--chart-1…5` is one designed ramp, and a sixth
* line would either repeat a step or invent a colour outside the system.
* Past five a legend is a puzzle, and five is as far as the palette keeps any
* two lines apart for a colour-blind reader too, not only neighbouring ones.
*/
export const MAX_SERIES = 5
/**
* The ramp's slots, as a palette names them.
* The palette's slots, as a palette names them: `--chart-1…8`.
*
* The whole set one may draw from — five, for the same reason `MAX_SERIES` is
* five, because they are the same five.
* One categorical palette, taken in order. The first slot is the brand
* primary and the rest are hues picked to stand apart from it and from each
* other, so a line is told by its hue rather than by a step of lightness.
*/
export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) =>
export const CHART_SLOTS = Array.from({ length: 8 }, (_, index) =>
String(index + 1),
)
/** No palette named; a chart left with this spreads itself. */
/** No palette named; a chart left with this takes the slots in order. */
export const NO_PALETTE: string[] = []
/**
* What a chart of *n* lines draws with when no palette was named: the ramp
* spread, rather than its first *n* steps.
*
* Three lines used to take slots 1, 2 and 3, which are adjacent steps of a
* ramp that carries identity by lightness alone — at a 2px stroke they are
* close to one picture. The ends and the middle are as far apart as five slots
* allow, and the same reasoning gives every other count.
*
* This moved after the palette shipped, and moving it was safe *because* of
* how the palette is stored: a named palette is honoured exactly as written,
* so no stored document means anything different than it did. Only the
* unwritten default moved, and any dashboard that dislikes where it moved to
* can now name the old one. That inertness is the property worth keeping —
* not this table, which is a design decision and may move again.
* The slots a chart draws with: a named palette exactly as written, so no
* stored document changes meaning, and otherwise the shared one in order.
*/
const SPREADS: Record<number, string[]> = {
1: ["1"],
2: ["1", "5"],
3: ["1", "3", "5"],
4: ["1", "2", "4", "5"],
5: ["1", "2", "3", "4", "5"],
}
/** The slots a chart of `count` lines draws with, named palette or not. */
export function slotsFor(count: number, palette?: string[]): string[] {
if (palette?.length) return palette
return SPREADS[Math.min(Math.max(Math.trunc(count) || 1, 1), MAX_SERIES)]
export function slotsFor(palette?: string[]): string[] {
return palette?.length ? palette : CHART_SLOTS
}
/**
@@ -334,9 +313,7 @@ export function UplotChart({
let told: number | null = null
/** Whether the click about to arrive is the end of a drag. */
let dragging = false
// Resolved once for the whole chart: how many lines there are is part of
// which slots they take, when nothing named them.
const slots = slotsFor(labels.length, palette)
const slots = slotsFor(palette)
// One builder for the whole chart: it is a factory, and the series only
// need the function it returns.
const spline = smooth ? { paths: uPlot.paths.spline?.() } : {}
@@ -27,7 +27,7 @@ export function BarWidget({ widget }: WidgetProps) {
const live = useBoundValues(rows.map((row) => row.message as string))
// The dashboard's own data colours, in the order a chart would take them, so
// a bar and a chart of the same readings agree about which line is which.
const colors = slotsFor(rows.length, usePalette())
const colors = slotsFor(usePalette())
if (rows.length === 0) {
return <p className="text-muted-foreground">Pick a message.</p>
@@ -186,7 +186,7 @@ function RunsChart({ widget }: WidgetProps) {
* The same chart, drawn from a data context.
*
* One line per member per bound message: three runs of a two-series chart is
* six curves, capped like any other chart at what the ramp can tell apart.
* six curves, capped like any other chart at what the palette can tell apart.
*/
function ContextChart({
widget,
@@ -262,7 +262,8 @@ function LiveChart({ widget }: WidgetProps) {
) || DEFAULT_POINTS,
)
// The dashboard's own data colours; outside one this is the whole ramp.
// The dashboard's own data colours; outside one, none, and the chart takes
// the shared palette.
const palette = usePalette()
const histories = useQueries({
@@ -2,7 +2,7 @@
* 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
* the frontend's only runner is Playwright and picking a palette slot does not
* need a browser:
*
* cd frontend && bun run src/components/Dashboard/palette.check.ts
@@ -15,77 +15,46 @@
* 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.
* named no palette lands. It started as a one-hue ramp in order, was spread
* across that ramp, and is now the categorical `--chart-1…8` in order. That is
* a design decision, pinned here so it changes on purpose rather than by
* accident.
*/
import assert from "node:assert/strict"
import {
CHART_SLOTS,
MAX_SERIES,
NO_PALETTE,
slotsFor,
} from "@/components/Common/UplotChart"
// --- the default: spread, not the first n steps ---------------------------
// --- the default: the shared palette, in order ----------------------------
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(
CHART_SLOTS,
["1", "2", "3", "4", "5", "6", "7", "8"],
"eight slots, named as --chart-1…8 is",
)
for (const stored of [undefined, NO_PALETTE]) {
assert.deepEqual(
slotsFor(Number(lines)),
want,
`${lines} lines with no palette named`,
slotsFor(stored),
CHART_SLOTS,
"naming no palette, however it is stored, takes the slots in order",
)
}
// 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",
assert.ok(
MAX_SERIES <= CHART_SLOTS.length,
"a full chart never repeats a colour",
)
// 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 -----------------------
// A dashboard names colours now (`ui/core/theme.ts`), and a chart draws them
// as they were written: not re-spread, not widened, not reordered.
// as they were written: not re-spread, not widened, not reordered. A chart
// with more lines than it names cycles through them.
const NAMED = ["#2a9d8f", "#e9c46a"]
assert.deepEqual(
slotsFor(2, NAMED),
NAMED,
"a named palette is never re-spread",
)
assert.deepEqual(
slotsFor(5, NAMED),
NAMED,
"and it is not widened to fit more lines either — the chart cycles",
)
// Naming none means the same thing however it is stored: the chart spreads.
for (const stored of [undefined, NO_PALETTE]) {
for (let lines = 1; lines <= CHART_SLOTS.length; lines++) {
assert.deepEqual(
slotsFor(lines, stored),
SPREAD[lines],
`${lines} lines still spread`,
)
}
}
assert.deepEqual(slotsFor(NAMED), NAMED, "a named palette is drawn as written")
console.log("palette: ok")
@@ -172,14 +172,13 @@ const PaletteContext = createContext(NO_PALETTE)
/**
* The data colours everything drawn under it uses.
*
* The colours a palette named, in draw order — the primary first, then the
* accent, then whatever it listed after the roles. Empty when the dashboard
* names no palette, 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.
* The colours a palette named, in draw order — the primary first, then
* whatever it listed after the roles, then the shared palette's own hues.
* Empty when the dashboard names no palette: a chart then takes the shared
* `--chart-1…8` in order (`slotsFor`).
*
* 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
* Mounted by the view *and* the editor: an editor drawing the shared
* palette 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
@@ -203,7 +202,7 @@ export function PaletteProvider({
)
}
/** The ramp slots the dashboard around this widget named, or none. */
/** The data colours the dashboard around this widget named, or none. */
export const usePalette = () => useContext(PaletteContext)
const LockedContext = createContext(false)
@@ -125,7 +125,7 @@ export type BarReading = {
fraction: number
precision: number | null
unit?: string
/** A colour, or a slot of the app's ramp — whatever the palette named. */
/** A colour, or a slot of the app's palette — whatever was named. */
color: string
}
@@ -15,12 +15,12 @@
*/
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import {
contrast,
isLight,
luminance,
MAX_ROLES,
mix,
onColor,
parsePalette,
@@ -96,14 +96,34 @@ assert.equal(named.surface, CHOSEN[1])
assert.equal(named.primary, CHOSEN[2])
assert.equal(named.accent, CHOSEN[3])
assert.equal(named.foreground, CHOSEN[4])
assert.deepEqual(
named.series.slice(0, 2),
[CHOSEN[2], CHOSEN[3]],
"a chart draws with the primary first, then the accent",
assert.equal(named.series[0], CHOSEN[2], "a chart draws with the primary first")
assert.ok(
!named.series.includes(CHOSEN[3]),
"the accent is a role, not a data colour",
)
const sixth = rolesOf([...CHOSEN, "#8ab17d"])
assert.equal(sixth?.series[2], "#8ab17d", "a sixth colour is the third series")
assert.equal(sixth?.series[1], "#8ab17d", "a sixth colour is the second series")
// After the palette's own, the shared hues: the literals `theme.ts` carries
// for a canvas are the ones `index.css` declares, theme by theme.
const css = readFileSync(
new URL("../../../../index.css", import.meta.url),
"utf8",
)
const block = (opener: string) => css.slice(css.indexOf(opener)).split("}")[0]
const slots = (text: string) =>
[...text.matchAll(/--chart-([2-8]): (#[0-9a-f]{6});/g)].map((m) => m[2])
assert.deepEqual(
rolesOf(["#ffffff"])?.series.slice(1),
slots(block(":root {")),
"a light dashboard continues with the light --chart-2…8",
)
assert.deepEqual(
rolesOf(["#0a0a0a"])?.series.slice(1),
slots(block(".dark {")),
"a dark dashboard continues with the dark --chart-2…8",
)
const dark = rolesOf(["#0f172a"])
assert.ok(dark)
@@ -126,8 +146,8 @@ assert.equal(rolesOf([]), null, "no colours is no palette")
for (const roles of [named, dark, light, sixth]) {
assert.ok(roles)
assert.ok(
roles.series.length >= MAX_ROLES,
"a chart of five lines never has to repeat a colour",
roles.series.length >= 8,
"a chart never has to repeat a colour before its ninth line",
)
}
@@ -4,6 +4,7 @@
* A palette is an ordered list of hex colours — pasted from a coolors.co link
* or typed — and position is the role: background, surface, primary, accent,
* text, then anything after that is another colour for a chart to draw with.
* A chart starts on the primary, then those, then the shared palette's own.
* Trailing roles may simply be left off; what is missing is derived from what
* is there, so three colours are a whole dashboard.
*
@@ -39,6 +40,32 @@ const BRAND = {
dark: { primary: "#7ba3b8", accent: "#e5a184" },
} as const
/**
* `--chart-2…8` from `index.css`, per theme, as literals: a chart canvas cannot
* read a custom property, and the slot a dashboard's own colours push them to
* is not the one the stylesheet put them in. Keep in step with `index.css`.
*/
const CHART = {
light: [
"#e26f00",
"#6f49ca",
"#00721b",
"#c66ae1",
"#966a00",
"#00a093",
"#b32474",
],
dark: [
"#d26600",
"#8d71e6",
"#55c15e",
"#dd8bf6",
"#ae7c00",
"#00c0b1",
"#bf4483",
],
} as const
/**
* The hex colours in a piece of text, in the order they appear.
*
@@ -150,7 +177,7 @@ export type Roles = {
primary: string
accent: string
foreground: string
/** Data colours, in draw order. At least five, so a full chart never repeats. */
/** Data colours, in draw order. At least eight, so a full chart never repeats. */
series: string[]
}
@@ -164,7 +191,8 @@ export function rolesOf(hexes: string[]): Roles | null {
const [background, surface, primary, accent, foreground, ...extra] = hexes
if (!background) return null
const brand = BRAND[isLight(background) ? "light" : "dark"]
const theme = isLight(background) ? "light" : "dark"
const brand = BRAND[theme]
const fore = foreground ?? onColor(background)
const roles: Roles = {
background,
@@ -178,17 +206,9 @@ export function rolesOf(hexes: string[]): Roles | null {
foreground: fore,
series: [],
}
roles.series = [roles.primary, roles.accent, ...extra]
// Enough colours for a full chart, taken from the two the palette named
// rather than from a ramp the dashboard never chose.
for (const tint of [
mix(roles.primary, fore, 0.35),
mix(roles.accent, fore, 0.35),
mix(roles.primary, roles.accent, 0.5),
]) {
if (roles.series.length >= MAX_ROLES) break
roles.series.push(tint)
}
// The palette's own primary leads, as `--primary` leads the shared one, and
// the rest are hues chosen to stand apart rather than shades of the scheme.
roles.series = [roles.primary, ...extra, ...CHART[theme]]
return roles
}
@@ -229,5 +249,8 @@ export function tokenStyle(roles: Roles): Record<string, string> {
"--chart-3": series[2],
"--chart-4": series[3],
"--chart-5": series[4],
"--chart-6": series[5],
"--chart-7": series[6],
"--chart-8": series[7],
}
}
@@ -70,7 +70,7 @@ export function useAnimatedFraction(fraction: number): MotionValue<number> {
/**
* A palette entry as CSS.
*
* A dashboard names colours, and a slot of the app's own ramp is what a
* A dashboard names colours, and a slot of the app's own palette is what a
* document written before it could says instead — so one is used as it stands
* and the other is looked up.
*/
+4 -4
View File
@@ -6,10 +6,10 @@ import { Sparkline } from "@/components/Common/Sparkline"
*
* Sixty slices of whatever window is selected, so the curve stays the same
* width and only its resolution moves. The same curve the node panel and the
* edge popover draw, dot included, in the chart ramp this page's other graphs
* use. The dot marks the newest slice rather than this instant: the server
* holds the slice that is still filling back, so the curve ends on one that is
* all there.
* edge popover draw, dot included, in the first chart colour this page's
* other graphs open with. The dot marks the newest slice rather than this
* instant: the server holds the slice that is still filling back, so the
* curve ends on one that is all there.
*/
export function Spark({ counts }: { counts: number[] }) {
const points: HistoryPoint[] = counts.map((value, index) => ({
+30 -17
View File
@@ -75,6 +75,9 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
--color-chart-7: var(--chart-7);
--color-chart-8: var(--chart-8);
--font-mono:
ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas,
monospace;
@@ -90,13 +93,17 @@
*
* `--primary-nested` is that same hue and saturation a few steps deeper, for
* a reading drawn inside a `--primary` fill — the bar widget's stacked
* segments. No slot of the chart ramp clears the 3:1 non-text guideline
* against `--primary` (`--chart-5`, which the bar used, measures 2.53:1), so
* the nested fill needs a token of its own. It cannot clear 3:1 against
* `--muted` as well in dark: `--primary` and `--muted` are only 5.82:1 apart
* there, and a colour 3:1 from both would have to sit in a 9:1 gap. The
* segment is therefore drawn inside a gutter of the fill rather than ever
* bordering the track.
* segments. No slot of the one-hue chart ramp that came before the palette
* cleared the 3:1 non-text guideline against `--primary` (`--chart-5`, which
* the bar used, measured 2.53:1), so the nested fill needs a token of its
* own. It cannot clear 3:1 against `--muted` as well in dark: `--primary` and
* `--muted` are only 5.82:1 apart there, and a colour 3:1 from both would have
* to sit in a 9:1 gap. The segment is therefore drawn inside a gutter of the
* fill rather than ever bordering the track.
*
* `--chart-1…8` is one categorical palette, taken in order: slot 1 is
* `--primary`, the rest are hues that clear 3:1 against `--card` and stay
* apart under colour blindness. See DESIGN-GUIDELINES.md → Colour tokens.
*/
/* `.light` is the symmetric half of `.dark` below: both are plain classes, so
either themes a subtree as well as the whole document — which is what lets a
@@ -134,11 +141,14 @@
--sidebar-border: #e4e4e4;
--sidebar-ring: #4a7189;
--status-success: #5e8b6d;
--chart-1: #3f5f74;
--chart-2: #4f768e;
--chart-3: #688ba1;
--chart-4: #83a2b5;
--chart-5: #9eb8c9;
--chart-1: #4a7189;
--chart-2: #e26f00;
--chart-3: #6f49ca;
--chart-4: #00721b;
--chart-5: #c66ae1;
--chart-6: #966a00;
--chart-7: #00a093;
--chart-8: #b32474;
}
.dark {
@@ -172,11 +182,14 @@
--sidebar-border: #2a2a2a;
--sidebar-ring: #7ba3b8;
--status-success: #87b596;
--chart-1: #94b0be;
--chart-2: #7999aa;
--chart-3: #5f8396;
--chart-4: #496d7f;
--chart-5: #395767;
--chart-1: #7ba3b8;
--chart-2: #d26600;
--chart-3: #8d71e6;
--chart-4: #55c15e;
--chart-5: #dd8bf6;
--chart-6: #ae7c00;
--chart-7: #00c0b1;
--chart-8: #bf4483;
}
@layer base {