From 409a90db28ea843fd49521c7f25ff1515fb99dd1 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 6 Oct 2026 07:05:09 +0200 Subject: [PATCH] =?UTF-8?q?Draw=20charts=20from=20one=20categorical=20pale?= =?UTF-8?q?tte,=20--chart-1=E2=80=A68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/interface/dashboards.md | 10 +-- frontend/src/components/Common/UplotChart.tsx | 49 ++++-------- .../src/components/Dashboard/BarWidget.tsx | 2 +- .../src/components/Dashboard/ChartWidget.tsx | 5 +- .../src/components/Dashboard/palette.check.ts | 75 ++++++------------- .../src/components/Dashboard/settings.tsx | 15 ++-- .../components/Dashboard/ui/core/contract.ts | 2 +- .../Dashboard/ui/core/theme.check.ts | 36 +++++++-- .../src/components/Dashboard/ui/core/theme.ts | 49 ++++++++---- .../components/Dashboard/ui/core/values.ts | 2 +- frontend/src/components/Health/Spark.tsx | 8 +- frontend/src/index.css | 47 +++++++----- 12 files changed, 151 insertions(+), 149 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 4383f4f..2512c03 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -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 diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 85528de..ea9aeb2 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -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 = { - 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?.() } : {} diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx index 4b5756e..e7b3e96 100644 --- a/frontend/src/components/Dashboard/BarWidget.tsx +++ b/frontend/src/components/Dashboard/BarWidget.tsx @@ -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

Pick a message.

diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index c593b05..f2af7f7 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -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({ diff --git a/frontend/src/components/Dashboard/palette.check.ts b/frontend/src/components/Dashboard/palette.check.ts index cde7efb..3e9f3c9 100644 --- a/frontend/src/components/Dashboard/palette.check.ts +++ b/frontend/src/components/Dashboard/palette.check.ts @@ -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 = { - 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") diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx index 3882d8e..18cd319 100644 --- a/frontend/src/components/Dashboard/settings.tsx +++ b/frontend/src/components/Dashboard/settings.tsx @@ -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) diff --git a/frontend/src/components/Dashboard/ui/core/contract.ts b/frontend/src/components/Dashboard/ui/core/contract.ts index 746bdd0..3325ee4 100644 --- a/frontend/src/components/Dashboard/ui/core/contract.ts +++ b/frontend/src/components/Dashboard/ui/core/contract.ts @@ -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 } diff --git a/frontend/src/components/Dashboard/ui/core/theme.check.ts b/frontend/src/components/Dashboard/ui/core/theme.check.ts index 7e2c761..8c9a420 100644 --- a/frontend/src/components/Dashboard/ui/core/theme.check.ts +++ b/frontend/src/components/Dashboard/ui/core/theme.check.ts @@ -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", ) } diff --git a/frontend/src/components/Dashboard/ui/core/theme.ts b/frontend/src/components/Dashboard/ui/core/theme.ts index 9843ddf..622397e 100644 --- a/frontend/src/components/Dashboard/ui/core/theme.ts +++ b/frontend/src/components/Dashboard/ui/core/theme.ts @@ -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 { "--chart-3": series[2], "--chart-4": series[3], "--chart-5": series[4], + "--chart-6": series[5], + "--chart-7": series[6], + "--chart-8": series[7], } } diff --git a/frontend/src/components/Dashboard/ui/core/values.ts b/frontend/src/components/Dashboard/ui/core/values.ts index 649f417..eee04e5 100644 --- a/frontend/src/components/Dashboard/ui/core/values.ts +++ b/frontend/src/components/Dashboard/ui/core/values.ts @@ -70,7 +70,7 @@ export function useAnimatedFraction(fraction: number): MotionValue { /** * 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. */ diff --git a/frontend/src/components/Health/Spark.tsx b/frontend/src/components/Health/Spark.tsx index 2439ccb..30b70c5 100644 --- a/frontend/src/components/Health/Spark.tsx +++ b/frontend/src/components/Health/Spark.tsx @@ -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) => ({ diff --git a/frontend/src/index.css b/frontend/src/index.css index 1c2a116..be747f8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 {