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
122 changes: 122 additions & 0 deletions frontend/src/__tests__/chart-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Tests for chart colors from design tokens (issue #699).
*/
import fs from 'fs';
import path from 'path';
import {
CHART_TOKEN_FALLBACKS, SURFACE_FALLBACK, applyChartDefaults, chartColor, chartPalette, requireToken,
surfaceColor, withAlpha, type ChartToken,
} from '../lib/chart-theme';

const SRC = path.join(__dirname, '..');
const STYLES = path.join(SRC, 'styles');
const TOKENS = Object.keys(CHART_TOKEN_FALLBACKS) as ChartToken[];

/** The declarations of the first :root block in base.css. */
function baseRootTokens(): Map<string, string> {
const css = fs.readFileSync(path.join(STYLES, 'base.css'), 'utf8');
const root = /:root\s*\{([\s\S]*?)\n\}/.exec(css)![1]!;
const out = new Map<string, string>();
for (const m of root.matchAll(/(--cudly-[\w-]+)\s*:\s*([^;]+);/g)) out.set(m[1]!, m[2]!.trim());
return out;
}

/** Hex / rgb() / hsl() color literals in CSS, ignoring comments, token definitions and var() fallbacks. */
function cssColorLiterals(css: string): string[] {
const code = css
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => !/^\s*--[\w-]+\s*:/.test(line))
.join('\n')
.replace(/var\(--[\w-]+\s*,[^)]*\)/g, '');
return code.match(/#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\(/g) ?? [];
}

afterEach(() => {
document.documentElement.removeAttribute('style');
});

describe('chart-theme', () => {
it('fallbacks match the --cudly-chart-* tokens defined in base.css', () => {
const defined = baseRootTokens();
for (const name of TOKENS) expect([name, defined.get(name)]).toEqual([name, CHART_TOKEN_FALLBACKS[name]]);
const cssChartTokens = [...defined.keys()].filter((k) => k.startsWith('--cudly-chart-'));
expect(cssChartTokens.sort()).toEqual([...TOKENS].sort());
});

it('surfaceColor reads --cudly-surface, falling back to its base.css value', () => {
expect(baseRootTokens().get('--cudly-surface')).toBe(SURFACE_FALLBACK);
expect(surfaceColor()).toBe(SURFACE_FALLBACK);
document.documentElement.style.setProperty('--cudly-surface', '#1f2937');
expect(surfaceColor()).toBe('#1f2937');
});

it('series and layer tokens are #rrggbb so charts can derive shades from them', () => {
for (const name of TOKENS) {
if (/text|grid/.test(name)) continue;
expect(CHART_TOKEN_FALLBACKS[name]).toMatch(/^#[0-9a-f]{6}$/);
}
});

it('chartColor prefers the live token over the fallback', () => {
expect(chartColor('--cudly-chart-1')).toBe('#1a73e8');
document.documentElement.style.setProperty('--cudly-chart-1', '#8ab4f8');
expect(chartColor('--cudly-chart-1')).toBe('#8ab4f8');
expect(chartPalette()[0]).toBe('#8ab4f8');
expect(chartPalette()).toHaveLength(10);
});

it('requireToken throws for a missing token', () => {
expect(() => requireToken('--cudly-does-not-exist')).toThrow('Missing CSS token --cudly-does-not-exist');
document.documentElement.style.setProperty('--cudly-primary', '#2563eb');
expect(requireToken('--cudly-primary')).toBe('#2563eb');
});

it('withAlpha turns #rrggbb into rgba()', () => {
expect(withAlpha('#1a73e8', 0.1)).toBe('rgba(26,115,232,0.1)');
expect(withAlpha('not-a-color', 0.5)).toBe('rgba(26,115,232,0.5)');
});

it('applyChartDefaults points Chart.js text and gridlines at the tokens', () => {
const defaults = { color: '#666', borderColor: 'rgba(0,0,0,0.1)' };
document.documentElement.style.setProperty('--cudly-chart-text', '#e5e7eb');
applyChartDefaults(defaults);
expect(defaults).toEqual({ color: '#e5e7eb', borderColor: 'rgba(0, 0, 0, 0.1)' });
expect(() => applyChartDefaults(undefined)).not.toThrow();
});

it('the dashboard follows a themed palette', () => {
const { savingsLayerColors } = require('../dashboard') as {
savingsLayerColors: () => { current: string; lowestOption: string; upside: string };
};
expect(savingsLayerColors().current).toBe('#2a78d6');
document.documentElement.style.setProperty('--cudly-chart-layer-current', '#8ab4f8');
expect(savingsLayerColors().current).toBe('#8ab4f8');
});
});

describe('color literals (theming ratchet)', () => {
// Chart modules must take every color from lib/chart-theme.ts.
it.each(['dashboard.ts', 'modules/savings-history.ts', 'ladder-timeline-chart.ts', 'trends.ts'])('%s has no color literals', (file) => {
const code = fs.readFileSync(path.join(SRC, file), 'utf8')
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.map((line) => line.replace(/\/\/.*$/, ''))
.join('\n');
expect(code.match(/'#[0-9a-fA-F]{3,8}'|\brgba?\(\s*\d/g)).toBeNull();
});

// Stylesheet literals bypass the tokens, so a theme cannot reach them.
// The baseline is the count on main when this ratchet landed. This ceiling
// may only go down: migrate a literal to a token, then lower it. Raising it
// means a new hard-coded color; use or add a token instead.
const CSS_LITERAL_CEILING = 113;

it(`styles/*.css hold at most ${CSS_LITERAL_CEILING} color literals outside token definitions`, () => {
const counts = fs.readdirSync(STYLES)
.filter((f) => f.endsWith('.css'))
.map((f) => [f, cssColorLiterals(fs.readFileSync(path.join(STYLES, f), 'utf8')).length] as const);
const total = counts.reduce((n, [, c]) => n + c, 0);
expect(total).toBeLessThanOrEqual(CSS_LITERAL_CEILING);
});
});
25 changes: 19 additions & 6 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1781,11 +1781,11 @@ describe('Dashboard Module', () => {
// Import the public helpers from dashboard. The module is already
// loaded above via the jest.mock chain, so we can import directly.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { renderSavingsByService, computeServiceStats, computeServiceStatsFromRecs, SAVINGS_LAYER_COLORS } = require('../dashboard') as {
const { renderSavingsByService, computeServiceStats, computeServiceStatsFromRecs, savingsLayerColors } = require('../dashboard') as {
renderSavingsByService: (recs: unknown[], byService?: Record<string, { potential_savings: number; current_savings: number }>, filterDesc?: string) => void;
computeServiceStats: (dataPoints: unknown[]) => Map<string, { min: number; max: number; sum: number; count: number; samples: number[] }>;
computeServiceStatsFromRecs: (recs: unknown[]) => Map<string, { min: number; max: number; sum: number; count: number; samples: number[]; minLabel?: string; maxLabel?: string }>;
SAVINGS_LAYER_COLORS: { current: string; lowestOption: string; upside: string };
savingsLayerColors: () => { current: string; lowestOption: string; upside: string };
};

function buildDOM(): void {
Expand Down Expand Up @@ -2056,6 +2056,19 @@ describe('Dashboard Module', () => {
expect(d.borderColor).toBe('#ffffff');
}
});

test('the segment gap follows a themed --cudly-surface (#699)', () => {
buildDOM();
document.documentElement.style.setProperty('--cudly-surface', '#1f2937');
try {
renderSavingsByService([rec('ec2', 100), rec('ec2', 300, 3)], { ec2: { potential_savings: 300, current_savings: 40 } });
const calls = (Chart as unknown as jest.Mock).mock.calls;
const datasets = (calls[calls.length - 1]?.[1] as { data: { datasets: { borderColor: string }[] } }).data.datasets;
for (const d of datasets) expect(d.borderColor).toBe('#1f2937');
} finally {
document.documentElement.style.removeProperty('--cudly-surface');
}
});
});

test('shows empty state and hides canvas when no recommendations', () => {
Expand Down Expand Up @@ -2158,9 +2171,9 @@ describe('Dashboard Module', () => {
const cfg = lastCfg();
const colours = Object.fromEntries(cfg.data.datasets.map((d) => [d.label, d.backgroundColor]));
expect(colours).toEqual({
'Current / Committed': SAVINGS_LAYER_COLORS.current,
'Lowest option': SAVINGS_LAYER_COLORS.lowestOption,
Upside: SAVINGS_LAYER_COLORS.upside,
'Current / Committed': savingsLayerColors().current,
'Lowest option': savingsLayerColors().lowestOption,
Upside: savingsLayerColors().upside,
});
// A single string (not a per-bar array) is what Chart.js's default
// legend draws, so swatch and bars cannot diverge.
Expand All @@ -2169,7 +2182,7 @@ describe('Dashboard Module', () => {
});

test('the three layers use three distinct colours', () => {
expect(new Set(Object.values(SAVINGS_LAYER_COLORS)).size).toBe(3);
expect(new Set(Object.values(savingsLayerColors())).size).toBe(3);
});

test('colours do not change when a service changes rank', () => {
Expand Down
47 changes: 30 additions & 17 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { buildKpiTile } from './lib/kpi-tile';
import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from './lib/upcoming-scope';
import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS, upsertChart } from './modules/chart-utils';
import { setChartSummary, renderChartTable, removeChartTable } from './lib/chart-a11y';
import { applyChartDefaults, chartColor, surfaceColor, withAlpha } from './lib/chart-theme';
// Re-export for backward compatibility (tests and other callers import from dashboard.ts).
export { formatTrendAxisTick } from './modules/chart-utils';

Expand All @@ -41,8 +42,11 @@ let savingsByServiceChart: Chart | null = null;
const SAVINGS_BY_SERVICE_MAX = 10;

// #744: a 2px card-coloured gap on top of every stacked segment, so adjacent
// layers are separated by an edge as well as by their layer colour.
const SEGMENT_GAP = { borderColor: '#ffffff', borderWidth: { top: 2, right: 0, bottom: 0, left: 0 } } as const;
// layers are separated by an edge as well as by their layer colour. The
// colour is the --cudly-surface token (#699), read at render time.
function segmentGap() {
return { borderColor: surfaceColor(), borderWidth: { top: 2, right: 0, bottom: 0, left: 0 } } as const;
}

// Ids of the "View as table" text alternatives for the Home charts (#744).
const SAVINGS_BY_SERVICE_TABLE_ID = 'savings-by-service-table';
Expand All @@ -52,13 +56,17 @@ const SAVINGS_TREND_TABLE_ID = 'savings-trend-table';
// are exactly the colours drawn (#189). The x axis already names each service,
// so hue encodes the layer, not the service: no colour can change with rank
// (#700) and the layers differ by hue rather than by shades of one (#744).
// Slots 1-3 of the validated categorical palette, CVD-distinct in every pair.
// Upside (aqua) sits below 3:1 on white; the tooltip carries its value.
export const SAVINGS_LAYER_COLORS = {
current: '#2a78d6', // blue
lowestOption: '#eb6834', // orange
upside: '#1baf7a', // aqua
} as const;
// Defaults are slots 1-3 of the validated categorical palette, CVD-distinct in
// every pair; Upside (aqua) sits below 3:1 on white, so the tooltip carries its
// value. The colours are the --cudly-chart-layer-* design tokens (issue #699,
// lib/chart-theme.ts), read at render time so a theme reaches the chart.
export function savingsLayerColors(): { current: string; lowestOption: string; upside: string } {
return {
current: chartColor('--cudly-chart-layer-current'),
lowestOption: chartColor('--cudly-chart-layer-lowest'),
upside: chartColor('--cudly-chart-layer-upside'),
};
}

// In-memory index of the currently-rendered upcoming purchases, keyed by
// execution_id. The "View Details" affordance renders from this — the
Expand Down Expand Up @@ -1019,6 +1027,9 @@ export function renderSavingsByService(
formatCurrency(e.upside), formatCurrency(e.visibleTotal)]),
});

const layerColors = savingsLayerColors();
const gap = segmentGap();
applyChartDefaults(Chart.defaults);
// #747: update in place so a filter change doesn't replay the entry animation.
savingsByServiceChart = upsertChart(savingsByServiceChart, canvas, {
type: 'bar',
Expand All @@ -1031,25 +1042,25 @@ export function renderSavingsByService(
{
label: 'Current / Committed',
data: currentData,
backgroundColor: SAVINGS_LAYER_COLORS.current,
backgroundColor: layerColors.current,
borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 4 },
...SEGMENT_GAP,
...gap,
stack: 'savings',
},
{
label: 'Lowest option',
data: lowestOptionData,
backgroundColor: SAVINGS_LAYER_COLORS.lowestOption,
backgroundColor: layerColors.lowestOption,
borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 },
...SEGMENT_GAP,
...gap,
stack: 'savings',
},
{
label: 'Upside',
data: upsideData,
backgroundColor: SAVINGS_LAYER_COLORS.upside,
backgroundColor: layerColors.upside,
borderRadius: { topLeft: 4, topRight: 4, bottomLeft: 0, bottomRight: 0 },
...SEGMENT_GAP,
...gap,
stack: 'savings',
},
],
Expand Down Expand Up @@ -1247,15 +1258,17 @@ export async function loadSavingsTrendChart(): Promise<void> {
rows: chartData.map(p => [formatTrendAxisTick(p.x, interval), formatCurrency(p.y)]),
});

const trendColor = chartColor('--cudly-chart-1');
applyChartDefaults(Chart.defaults);
// #747: update in place so a range click doesn't replay the entry animation.
savingsTrendChart = upsertChart(savingsTrendChart, canvas, {
type: 'line',
data: {
datasets: [{
label: 'Cumulative savings',
data: chartData,
borderColor: '#1a73e8',
backgroundColor: 'rgba(26, 115, 232, 0.1)',
borderColor: trendColor,
backgroundColor: withAlpha(trendColor, 0.1),
fill: true,
tension: 0.25,
pointRadius: 2,
Expand Down
8 changes: 1 addition & 7 deletions frontend/src/ladder-timeline-chart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ChartConfiguration } from 'chart.js';
import type { LadderEvent, LadderRun } from './api/ladder';
import { formatDate } from './utils';
import { requireToken as token } from './lib/chart-theme';
import { LADDER_COUNTED_NOTE, formatLadderAmount, formatLadderUnits, ladderAmountUnits, ladderCumulative, ladderLabel, type LadderCumulative } from './ladder-timeline-filters';

export const LADDER_UNIT_LABEL = 'On-demand-equivalent $/hr';
Expand All @@ -10,13 +11,6 @@ const layerTokens: Record<string, string> = {
'azure-reservation': '--cudly-error', 'azure-savings-plan': '--cudly-info',
};

/** Resolve a design token; a missing token is a bug, not a reason to paint a default color. */
function token(name: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!value) throw new Error(`Missing CSS token ${name}`);
return value;
}

export interface LadderChartSeries { label: string; layer?: string; values: bigint[]; kind: 'base' | 'layer' | 'target' }

export interface LadderChartModel {
Expand Down
Loading
Loading