commit 75e9dae007999d929ef08583252b1a3e24703db9
parent e4bc59181ffa629a0c7c6d4fb36383af208a3398
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 12:30:49 -0400
charts: ChartView takes its colours from seriesColor(i), so the sixth series reaches --chart-6
ChartView cycled its own `var(--chart-${(i % 5) + 1})` for series and pie
slices, so the sixth series repeated --chart-1 and the slot added in release
11 was never used outside the hub chart. Series 1–5 are unchanged; the sixth
is --chart-6; past six, the hub chart's golden-angle hues rather than a
repeat. ChartView.test.ts renders it and reads the ChartContainer's <style>
declarations (fails on the old code: s5 = var(--chart-1)).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 82 insertions(+), 3 deletions(-)
diff --git a/common/components/charts/ChartView.test.ts b/common/components/charts/ChartView.test.ts
@@ -0,0 +1,73 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { ChartView } from "./ChartView";
+import { newChart, type ChartConfig } from "../../lib/chartConfig";
+import type { ChartData } from "../../lib/chartAggregate";
+import { seriesColor } from "../../lib/homepageChart";
+
+// ChartView's colours (release 13, slice W2): a series' colour — and a pie
+// slice's — is seriesColor(i), so the sixth reaches --chart-6 instead of
+// wrapping back to --chart-1 (the old `i % 5`). What a server render can see
+// is the ChartContainer's <style>: one `--color-<id>: <colour>;` per series,
+// which every Line/Area/Bar then paints with. The marks themselves need a
+// measured container, which a static render does not have.
+
+// common's tsconfig has `jsx: "preserve"` (Next compiles it), so tsx falls
+// back to the classic transform — `React.createElement` on a free `React`.
+(globalThis as { React?: typeof React }).React = React;
+
+function data(n: number): ChartData {
+ const categories = ["2026-01", "2026-02"];
+ return {
+ categories,
+ series: Array.from({ length: n }, (_, i) => ({
+ key: `channel ${i + 1}`,
+ points: categories.map((x, j) => ({ x, y: i + j + 1 })),
+ })),
+ };
+}
+
+function render(d: ChartData, config: ChartConfig): string {
+ return renderToStaticMarkup(React.createElement(ChartView, { data: d, config }));
+}
+
+// The declarations of the light-theme block (ChartStyle writes the same list
+// once per theme), as [id, colour] pairs.
+function colourDecls(html: string): [string, string][] {
+ const block = html.match(/\[data-chart=[^\]]+\] \{([^}]*)\}/);
+ assert.ok(block, "the chart's <style> block is rendered");
+ return [...block[1].matchAll(/--color-([\w-]+): ([^;]+);/g)].map((m) => [m[1], m[2]]);
+}
+
+test("a grouped chart's sixth series is --chart-6, and the first five are unchanged", () => {
+ for (const type of ["line", "area", "bar", "stackedBar"] as const) {
+ const html = render(data(7), newChart({ type, groupBy: "channel" }));
+ assert.deepEqual(colourDecls(html), [
+ ["s0", "var(--chart-1)"],
+ ["s1", "var(--chart-2)"],
+ ["s2", "var(--chart-3)"],
+ ["s3", "var(--chart-4)"],
+ ["s4", "var(--chart-5)"],
+ ["s5", "var(--chart-6)"],
+ // Past the six validated slots: the hub chart's golden-angle hue, not
+ // a repeat of --chart-2.
+ ["s6", seriesColor(6)],
+ ], type);
+ assert.notEqual(seriesColor(6), "var(--chart-2)");
+ }
+});
+
+test("a pie's sixth slice is --chart-6", () => {
+ const d: ChartData = {
+ categories: ["a", "b", "c", "d", "e", "f", "g"],
+ series: [{ key: "all", points: "abcdefg".split("").map((x, i) => ({ x, y: i + 1 })) }],
+ };
+ const html = render(d, newChart({ type: "pie" }));
+ assert.deepEqual(
+ colourDecls(html).map(([id, c]) => `${id}=${c}`),
+ Array.from({ length: 7 }, (_, i) => `p${i}=${seriesColor(i)}`),
+ );
+ assert.ok(html.includes("--color-p5: var(--chart-6);"));
+});
diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx
@@ -26,6 +26,7 @@ import {
import { useMediaQuery } from "../../lib/useMediaQuery";
import type { ChartData } from "../../lib/chartAggregate";
import { xAxisLabel, yAxisLabel, type ChartConfig } from "../../lib/chartConfig";
+import { seriesColor } from "../../lib/homepageChart";
const AXIS_LABEL_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 };
@@ -33,12 +34,17 @@ const AXIS_LABEL_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 };
// can't appear in `--color-<key>`), while keeping the human label for legends.
// An ungrouped chart has a single "all" series — label it by its Y metric
// (e.g. "Total views") rather than the meaningless internal key.
+//
+// A series' colour (and a pie slice's) is seriesColor(i), the one the hub's
+// cross-site chart uses: the six validated slots --chart-1..6, then hues
+// spread by the golden angle. This chart used to cycle its own `i % 5`, so
+// the sixth series repeated the first and --chart-6 was never reached.
function seriesMeta(data: ChartData, config: ChartConfig) {
const single = config.groupBy === "none";
return data.series.map((s, i) => ({
id: `s${i}`,
label: single ? yAxisLabel(config) : s.key,
- color: `var(--chart-${(i % 5) + 1})`,
+ color: seriesColor(i),
}));
}
@@ -89,7 +95,7 @@ export function ChartView({
}));
const pieConfig: ShadcnChartConfig = {};
data.categories.forEach((label, i) => {
- pieConfig[`p${i}`] = { label, color: `var(--chart-${(i % 5) + 1})` };
+ pieConfig[`p${i}`] = { label, color: seriesColor(i) };
});
return (
<ChartContainer config={pieConfig} className="h-[240px] sm:h-[320px] w-full">
@@ -97,7 +103,7 @@ export function ChartView({
<ChartTooltip content={<ChartTooltipContent />} />
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={40}>
{pieData.map((_, i) => (
- <Cell key={i} fill={`var(--chart-${(i % 5) + 1})`} />
+ <Cell key={i} fill={seriesColor(i)} />
))}
</Pie>
</PieChart>