Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions app/e2e/design-system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import {
} from "./fixtures";

// ---------------------------------------------------------------------------
// Design system — Deep Ocean palette, accessibility, colorblind mode
// Design system — Graphite & Citrine palette, accessibility, colorblind mode
// ---------------------------------------------------------------------------

test.describe("Design system — Deep Ocean palette & accessibility", () => {
test.describe("Design system — Graphite & Citrine palette & accessibility", () => {
let dashboardCleanup: (() => Promise<void>) | undefined;

test.beforeEach(async ({ authPage, page }) => {
Expand Down Expand Up @@ -63,9 +63,9 @@ test.describe("Design system — Deep Ocean palette & accessibility", () => {
return dialog;
}

// ── Deep Ocean palette ────────────────────────────────────────────────
// ── Citrine palette ───────────────────────────────────────────────────

test("Deep Ocean CSS custom properties are defined (10 chart colors)", async ({
test("Citrine CSS custom properties are defined (10 chart colors)", async ({
page,
}) => {
// Read --chart-1 through --chart-10 from the document root
Expand All @@ -82,19 +82,17 @@ test.describe("Design system — Deep Ocean palette & accessibility", () => {
expect(colors[i]).toMatch(/\d+\s+\d+%\s+\d+%/);
}

// First color should be Blue (hue ~217)
expect(colors[0]).toContain("217");
// First color is the Citrine amber brand accent (#821)
expect(colors[0]).toBe("38 95% 55%");
});

test("Deep Ocean neutrals have blue tint (not pure gray)", async ({
page,
}) => {
test("Graphite neutrals have cool tint (not pure gray)", async ({ page }) => {
const bg = await page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--background")
.trim(),
);
// Deep Ocean light: hue ~210, not 0
// Graphite & Citrine light background: hue 220, not 0 (#820)
expect(bg).toMatch(/^2\d+\s/);
});

Expand Down
40 changes: 20 additions & 20 deletions component/design-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,16 @@
--shadow-lg:
0 8px 24px -4px hsl(30 25% 10% / 0.12),
0 2px 6px -2px hsl(30 25% 10% / 0.06);
--chart-1: 217 91% 60%;
--chart-2: 38 92% 50%;
--chart-3: 347 77% 50%;
--chart-4: 160 84% 39%;
--chart-5: 271 81% 56%;
--chart-6: 24 90% 48%;
--chart-7: 142 71% 45%;
--chart-8: 199 89% 48%;
--chart-9: 326 78% 42%;
--chart-10: 55 70% 45%;
--chart-1: 38 95% 55%;
--chart-2: 185 70% 48%;
--chart-3: 265 55% 48%;
--chart-4: 350 70% 48%;
--chart-5: 95 45% 66%;
--chart-6: 330 65% 38%;
--chart-7: 240 55% 66%;
--chart-8: 15 75% 58%;
--chart-9: 172 65% 38%;
--chart-10: 150 55% 66%;
}

.dark {
Expand Down Expand Up @@ -92,14 +92,14 @@
0 2px 6px -1px hsl(220 30% 2% / 0.5), 0 1px 2px -1px hsl(220 30% 2% / 0.4);
--shadow-lg:
0 8px 24px -4px hsl(220 30% 2% / 0.6), 0 2px 6px -2px hsl(220 30% 2% / 0.5);
--chart-1: 217 91% 65%;
--chart-2: 38 92% 56%;
--chart-3: 347 77% 55%;
--chart-4: 160 70% 50%;
--chart-5: 271 81% 65%;
--chart-6: 24 90% 55%;
--chart-7: 142 71% 50%;
--chart-8: 199 89% 55%;
--chart-9: 326 78% 50%;
--chart-10: 55 70% 52%;
--chart-1: 38 95% 58%;
--chart-2: 185 65% 52%;
--chart-3: 265 55% 56%;
--chart-4: 350 70% 55%;
--chart-5: 95 45% 68%;
--chart-6: 330 60% 46%;
--chart-7: 240 55% 70%;
--chart-8: 15 75% 62%;
--chart-9: 172 60% 44%;
--chart-10: 150 50% 68%;
}
10 changes: 5 additions & 5 deletions component/src/charts/__tests__/base-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe("BaseChart", () => {
render(<BaseChart options={{ title: { text: "Test" } }} />);
expect(mockSetOption).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.arrayContaining(["hsl(217, 91%, 60%)"]),
color: expect.arrayContaining(["hsl(38, 95%, 55%)"]),
}),
{ notMerge: true },
);
Expand Down Expand Up @@ -158,10 +158,10 @@ describe("BaseChart", () => {

it("uses default palette colors when no colorPalette is specified", () => {
render(<BaseChart options={{ title: { text: "Test" } }} />);
// Default uses resolveChartColors() which falls back to DEEP_OCEAN_LIGHT
// Default uses resolveChartColors() which falls back to CITRINE_LIGHT
expect(mockSetOption).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.arrayContaining(["hsl(217, 91%, 60%)"]),
color: expect.arrayContaining(["hsl(38, 95%, 55%)"]),
}),
{ notMerge: true },
);
Expand All @@ -177,7 +177,7 @@ describe("BaseChart", () => {
// deep-ocean triggers the default CSS-var path (same as unset)
expect(mockSetOption).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.arrayContaining(["hsl(217, 91%, 60%)"]),
color: expect.arrayContaining(["hsl(38, 95%, 55%)"]),
}),
{ notMerge: true },
);
Expand Down Expand Up @@ -213,7 +213,7 @@ describe("BaseChart", () => {
// getPaletteColors returns undefined for unknown IDs → falls back to resolveChartColors
expect(mockSetOption).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.arrayContaining(["hsl(217, 91%, 60%)"]),
color: expect.arrayContaining(["hsl(38, 95%, 55%)"]),
}),
{ notMerge: true },
);
Expand Down
118 changes: 118 additions & 0 deletions component/src/charts/__tests__/citrine-cvd-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { CITRINE_LIGHT, CITRINE_DARK } from "../theme";

/**
* Colorblind-safety contract for the Citrine chart palette (#821).
*
* Simulates protanopia / deuteranopia / tritanopia (Viénot et al. 1999,
* applied in linear RGB) and asserts the minimum pairwise CIE76 deltaE
* stays above thresholds — for the critical first 5 colors (typical
* series count) and for the full 10.
*
* Reference: the old Deep Ocean palette scored as low as deltaE 4 (protan,
* all-10) and 8 (tritan, first-5). Citrine was optimized to dominate it on
* every axis; these thresholds lock that in.
*/

const hsl2rgb = (h: number, s: number, l: number): number[] => {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [f(0), f(8), f(4)];
};
const srgb2lin = (c: number) =>
c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const lin2srgb = (c: number) =>
c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;

const SIM_MATRICES: Record<string, number[][]> = {
protan: [
[0.11238, 0.88762, 0],
[0.11238, 0.88762, 0],
[0.00401, -0.00401, 1],
],
deutan: [
[0.29275, 0.70725, 0],
[0.29275, 0.70725, 0],
[-0.02234, 0.02234, 1],
],
tritan: [
[1, 0.14461, -0.14461],
[0, 0.85924, 0.14076],
[0, 0.85924, 0.14076],
],
};

const simulate = (rgb: number[], kind: string): number[] => {
const lin = rgb.map(srgb2lin);
return SIM_MATRICES[kind].map((row) =>
lin2srgb(
Math.max(
0,
Math.min(1, row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]),
),
),
);
};

const rgb2lab = ([r, g, b]: number[]): number[] => {
let x = 0.4124 * srgb2lin(r) + 0.3576 * srgb2lin(g) + 0.1805 * srgb2lin(b);
let y = 0.2126 * srgb2lin(r) + 0.7152 * srgb2lin(g) + 0.0722 * srgb2lin(b);
let z = 0.0193 * srgb2lin(r) + 0.1192 * srgb2lin(g) + 0.9505 * srgb2lin(b);
x /= 0.95047;
z /= 1.08883;
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
return [116 * f(y) - 16, 500 * (f(x) - f(y)), 200 * (f(y) - f(z))];
};

const deltaE = (a: number[], b: number[]): number => {
const la = rgb2lab(a);
const lb = rgb2lab(b);
return Math.hypot(la[0] - lb[0], la[1] - lb[1], la[2] - lb[2]);
};

function parseHsl(c: string): number[] {
const m = c.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!m) throw new Error(`unparseable color: ${c}`);
return hsl2rgb(Number(m[1]), Number(m[2]), Number(m[3]));
}

function minPairwise(colors: string[], kind: string): number {
const rgbs = colors.map(parseHsl);
const sims = kind === "normal" ? rgbs : rgbs.map((c) => simulate(c, kind));
let min = Infinity;
for (let i = 0; i < sims.length; i++) {
for (let j = i + 1; j < sims.length; j++) {
min = Math.min(min, deltaE(sims[i], sims[j]));
}
}
return min;
}

const VISIONS = ["normal", "protan", "deutan", "tritan"] as const;

describe.each([
["CITRINE_LIGHT", CITRINE_LIGHT],
["CITRINE_DARK", CITRINE_DARK],
])("%s colorblind safety (#821)", (_name, palette) => {
it("anchors on the citrine amber accent", () => {
expect(palette[0]).toMatch(/^hsl\(38, 95%, 5\d%\)$/);
});

it.each(VISIONS)(
"first 5 colors keep min deltaE ≥ 20 under %s vision",
(kind) => {
expect(minPairwise(palette.slice(0, 5), kind)).toBeGreaterThanOrEqual(20);
},
);

it.each(VISIONS)(
"all 10 colors keep min deltaE ≥ 9 under %s vision",
(kind) => {
expect(minPairwise(palette, kind)).toBeGreaterThanOrEqual(9);
},
);
});
20 changes: 18 additions & 2 deletions component/src/charts/__tests__/line-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ describe("LineChart", () => {
expect(optionsCall.series[1].name).toBe("cost");
});

it("defaults to smooth, fine 1.5px lines with a subtle area fill (#822)", () => {
render(<LineChart data={sampleData} />);
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].smooth).toBe(true);
expect(optionsCall.series[0].lineStyle.width).toBe(1.5);
expect(optionsCall.series[0].areaStyle).toBeDefined();
expect(optionsCall.series[0].areaStyle.opacity).toBeLessThanOrEqual(0.15);
});

it("can disable smoothing and area fill explicitly", () => {
render(<LineChart data={sampleData} smooth={false} area={false} />);
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].smooth).toBe(false);
expect(optionsCall.series[0].areaStyle).toBeUndefined();
});

it("enables smooth mode", () => {
render(<LineChart data={sampleData} smooth />);
const optionsCall = mockSetOption.mock.calls[0][0];
Expand Down Expand Up @@ -130,10 +146,10 @@ describe("LineChart", () => {
expect(optionsCall.series[0].lineStyle.width).toBe(4);
});

it("defaults line width to 2", () => {
it("defaults line width to 1.5 (#822)", () => {
render(<LineChart data={sampleData} />);
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].lineStyle.width).toBe(2);
expect(optionsCall.series[0].lineStyle.width).toBe(1.5);
});

it("shows grid lines by default", () => {
Expand Down
28 changes: 20 additions & 8 deletions component/src/charts/__tests__/palettes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ describe("COLOR_PALETTES", () => {
expect(Object.keys(COLOR_PALETTES).length).toBeGreaterThanOrEqual(5);
});

it("contains 'deep-ocean' as the default palette", () => {
expect(COLOR_PALETTES["deep-ocean"]).toBeDefined();
it("contains 'citrine' as the default palette (#821)", () => {
expect(COLOR_PALETTES["citrine"]).toBeDefined();
});

it("every palette has a label string", () => {
Expand Down Expand Up @@ -39,8 +39,15 @@ describe("COLOR_PALETTES", () => {
}
});

it("'deep-ocean' palette label contains 'Default'", () => {
expect(COLOR_PALETTES["deep-ocean"].label).toContain("Default");
it("'citrine' palette label contains 'Default'", () => {
expect(COLOR_PALETTES["citrine"].label).toContain("Default");
});

it("no palette except citrine claims to be the default", () => {
for (const [id, palette] of Object.entries(COLOR_PALETTES)) {
if (id === "citrine") continue;
expect(palette.label, id).not.toContain("Default");
}
});

it("contains 'tableau' palette", () => {
Expand Down Expand Up @@ -71,10 +78,15 @@ describe("COLOR_PALETTES", () => {
);
});

it("deep-ocean colors match DEEP_OCEAN_LIGHT from theme", () => {
const deepOcean = COLOR_PALETTES["deep-ocean"];
expect(deepOcean.colors[0]).toBe("hsl(217, 91%, 60%)"); // Blue
expect(deepOcean.colors[1]).toBe("hsl(38, 92%, 50%)"); // Amber
it("citrine colors match CITRINE_LIGHT from theme, amber first (#821)", () => {
const citrine = COLOR_PALETTES["citrine"];
expect(citrine.colors[0]).toBe("hsl(38, 95%, 55%)"); // Citrine amber anchor
});

it("legacy 'deep-ocean' id aliases to the citrine default (#821)", () => {
expect(getPaletteColors("deep-ocean")).toEqual(
COLOR_PALETTES["citrine"].colors,
);
});
});

Expand Down
Loading
Loading