diff --git a/frontend/src/__tests__/chart-theme.test.ts b/frontend/src/__tests__/chart-theme.test.ts new file mode 100644 index 00000000..2579d099 --- /dev/null +++ b/frontend/src/__tests__/chart-theme.test.ts @@ -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 { + const css = fs.readFileSync(path.join(STYLES, 'base.css'), 'utf8'); + const root = /:root\s*\{([\s\S]*?)\n\}/.exec(css)![1]!; + const out = new Map(); + 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); + }); +}); diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 223a23d7..f8faf002 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -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, filterDesc?: string) => void; computeServiceStats: (dataPoints: unknown[]) => Map; computeServiceStatsFromRecs: (recs: unknown[]) => Map; - SAVINGS_LAYER_COLORS: { current: string; lowestOption: string; upside: string }; + savingsLayerColors: () => { current: string; lowestOption: string; upside: string }; }; function buildDOM(): void { @@ -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', () => { @@ -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. @@ -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', () => { diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 5f8cabb1..64d46a8e 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -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'; @@ -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'; @@ -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 @@ -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', @@ -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', }, ], @@ -1247,6 +1258,8 @@ export async function loadSavingsTrendChart(): Promise { 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', @@ -1254,8 +1267,8 @@ export async function loadSavingsTrendChart(): Promise { 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, diff --git a/frontend/src/ladder-timeline-chart.ts b/frontend/src/ladder-timeline-chart.ts index d79d90f6..898ae340 100644 --- a/frontend/src/ladder-timeline-chart.ts +++ b/frontend/src/ladder-timeline-chart.ts @@ -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'; @@ -10,13 +11,6 @@ const layerTokens: Record = { '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 { diff --git a/frontend/src/lib/chart-theme.ts b/frontend/src/lib/chart-theme.ts new file mode 100644 index 00000000..3a4ceb56 --- /dev/null +++ b/frontend/src/lib/chart-theme.ts @@ -0,0 +1,123 @@ +/** + * Chart colors from design tokens (issue #699). + * + * Chart.js paints on a , so its colors cannot come from a + * stylesheet the way the rest of the UI does: each chart has to read the + * CSS custom properties at render time. Before this module the dashboard + * and savings-history charts hard-coded their hex values, so a theme + * (a dark palette, a contrast fix) could restyle every surface except the + * charts. The `--cudly-chart-*` tokens live in styles/base.css next to the + * other design tokens; overriding them there restyles every chart. + * + * The values below are fallbacks for environments without the stylesheet + * (jsdom tests, a failed CSS load). They must match base.css; a jest test + * compares the two so they cannot drift. + * + * Series and layer tokens must be #rrggbb: the charts derive translucent + * shades from them (see withAlpha). + */ + +export const CHART_TOKEN_FALLBACKS = { + // General series palette (savings trend and savings-history lines use + // slots 1 and 2). + '--cudly-chart-1': '#1a73e8', + '--cudly-chart-2': '#34a853', + '--cudly-chart-3': '#fbbc04', + '--cudly-chart-4': '#ea4335', + '--cudly-chart-5': '#9c27b0', + '--cudly-chart-6': '#00bcd4', + '--cudly-chart-7': '#ff5722', + '--cudly-chart-8': '#607d8b', + '--cudly-chart-9': '#795548', + '--cudly-chart-10': '#e91e63', + // Cumulative savings line on the savings-history chart. + '--cudly-chart-cumulative': '#4285f4', + // Per-service savings chart: one hue per stacked layer (#189), the same + // on every bar so Chart.js's default legend swatch matches the bars. + '--cudly-chart-layer-current': '#2a78d6', + '--cudly-chart-layer-lowest': '#eb6834', + '--cudly-chart-layer-upside': '#1baf7a', + // Axis labels and gridlines (Chart.js's own defaults until now). + '--cudly-chart-text': '#666666', + '--cudly-chart-grid': 'rgba(0, 0, 0, 0.1)', + '--cudly-chart-grid-faint': 'rgba(0, 0, 0, 0.05)', +} as const; + +export type ChartToken = keyof typeof CHART_TOKEN_FALLBACKS; + +const SERIES: readonly ChartToken[] = [ + '--cudly-chart-1', '--cudly-chart-2', '--cudly-chart-3', '--cudly-chart-4', '--cudly-chart-5', + '--cudly-chart-6', '--cudly-chart-7', '--cudly-chart-8', '--cudly-chart-9', '--cudly-chart-10', +]; + +/** The computed value of a CSS custom property on :root, or '' when unset. */ +export function readToken(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +/** Resolve a design token; a missing token is a bug, not a reason to paint a default color. */ +export function requireToken(name: string): string { + const value = readToken(name); + if (!value) throw new Error(`Missing CSS token ${name}`); + return value; +} + +/** A chart color: the token's themed value, else its base.css default. */ +export function chartColor(name: ChartToken): string { + return readToken(name) || CHART_TOKEN_FALLBACKS[name]; +} + +/** The series palette, in slot order. */ +export function chartPalette(): string[] { + return SERIES.map(chartColor); +} + +/** + * Parse a #rrggbb hex string into its r/g/b components. Falls back to the + * first series color for malformed input so a chart never renders a NaN + * color. + */ +export function parseHexColor(hex: string): { r: number; g: number; b: number } { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()); + const clean = m ? m[1]! : CHART_TOKEN_FALLBACKS['--cudly-chart-1'].slice(1); + return { + r: parseInt(clean.substring(0, 2), 16), + g: parseInt(clean.substring(2, 4), 16), + b: parseInt(clean.substring(4, 6), 16), + }; +} + +/** The same #rrggbb color at the given opacity, as rgba(). */ +export function withAlpha(hex: string, alpha: number): string { + const { r, g, b } = parseHexColor(hex); + return `rgba(${r},${g},${b},${alpha})`; +} + +/** Minimal shape of Chart.defaults this module writes, so tests need not load Chart.js. */ +interface ChartDefaultsLike { + color: unknown; + borderColor: unknown; +} + +/** + * Point Chart.js's global text and gridline colors at the theme tokens. + * Call right before creating a chart, not at import time: the stylesheet + * may not have applied yet when the module first loads. + */ +export function applyChartDefaults(defaults: ChartDefaultsLike | undefined): void { + // Unit tests replace Chart.js with a mock that has no defaults object. + if (!defaults) return; + defaults.color = chartColor('--cudly-chart-text'); + defaults.borderColor = chartColor('--cudly-chart-grid'); +} + +/** base.css default of --cudly-surface (card background), with the same fallback rule as above. */ +export const SURFACE_FALLBACK = '#ffffff'; + +/** + * The card surface color, for chart strokes that must read as background + * (the gap between stacked bar segments, #744). + */ +export function surfaceColor(): string { + return readToken('--cudly-surface') || SURFACE_FALLBACK; +} diff --git a/frontend/src/modules/savings-history.ts b/frontend/src/modules/savings-history.ts index f45b7fc2..df00cef5 100644 --- a/frontend/src/modules/savings-history.ts +++ b/frontend/src/modules/savings-history.ts @@ -8,6 +8,7 @@ import * as state from '../state'; import { formatTrendAxisTick, upsertChart } from './chart-utils'; import { formatCompactCurrency, formatCurrency } from '../utils'; import { setChartSummary, renderChartTable, removeChartTable } from '../lib/chart-a11y'; +import { applyChartDefaults, chartColor, withAlpha } from '../lib/chart-theme'; // Register Chart.js components Chart.register(...registerables); @@ -402,13 +403,15 @@ function renderSavingsChart( const axisMaxMs = periodEnd ? periodEnd.getTime() : nowMs; const label = cumulative ? 'Cumulative Savings' : 'Period Savings'; + const periodColor = chartColor('--cudly-chart-2'); + const cumulativeColor = chartColor('--cudly-chart-cumulative'); const dataset = cumulative ? { type: 'line' as const, label, data: points, - borderColor: '#4285f4', - backgroundColor: 'rgba(66, 133, 244, 0.1)', + borderColor: cumulativeColor, + backgroundColor: withAlpha(cumulativeColor, 0.1), fill: true, tension: 0.3, pointRadius: dataPoints.length > 50 ? 0 : 3, @@ -418,8 +421,8 @@ function renderSavingsChart( type: 'bar' as const, label, data: points, - backgroundColor: 'rgba(52, 168, 83, 0.7)', - borderColor: '#34a853', + backgroundColor: withAlpha(periodColor, 0.7), + borderColor: periodColor, borderWidth: 1, maxBarThickness: 24, }; @@ -445,6 +448,7 @@ function renderSavingsChart( ]), }); + applyChartDefaults(Chart.defaults); // #747: update in place so a period or unit change doesn't replay the // entry animation. Switching the series (#749) changes the chart type, // which upsertChart handles by recreating the chart. @@ -484,7 +488,7 @@ function renderSavingsChart( position: 'left', beginAtZero: true, grid: { - color: 'rgba(0, 0, 0, 0.05)', + color: chartColor('--cudly-chart-grid-faint'), }, ticks: { maxTicksLimit: 6, diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index bf665d4d..bda76425 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -81,6 +81,29 @@ --cudly-shadow-md: 0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); --cudly-shadow-lg: 0 8px 24px rgba(15, 23, 42, 0.08); + /* Charts (issue #699). Chart.js paints on canvas, so lib/chart-theme.ts + * reads these at render time; override them to restyle every chart. + * Series and layer colors must be #rrggbb (the charts derive shades + * from them). Keep lib/chart-theme.ts CHART_TOKEN_FALLBACKS in sync. */ + --cudly-chart-1: #1a73e8; + --cudly-chart-2: #34a853; + --cudly-chart-3: #fbbc04; + --cudly-chart-4: #ea4335; + --cudly-chart-5: #9c27b0; + --cudly-chart-6: #00bcd4; + --cudly-chart-7: #ff5722; + --cudly-chart-8: #607d8b; + --cudly-chart-9: #795548; + --cudly-chart-10: #e91e63; + --cudly-chart-cumulative: #4285f4; + /* Per-service savings chart layers (#189): one hue per stacked layer. */ + --cudly-chart-layer-current: #2a78d6; + --cudly-chart-layer-lowest: #eb6834; + --cudly-chart-layer-upside: #1baf7a; + --cudly-chart-text: #666666; + --cudly-chart-grid: rgba(0, 0, 0, 0.1); + --cudly-chart-grid-faint: rgba(0, 0, 0, 0.05); + /* Motion (issue #725): one easing curve and three durations so every * transition moves alike. Name the transitioned properties; never use * `transition: all`, which also animates layout and color changes that diff --git a/frontend/src/styles/charts.css b/frontend/src/styles/charts.css index a7860b99..47fd2946 100644 --- a/frontend/src/styles/charts.css +++ b/frontend/src/styles/charts.css @@ -93,7 +93,7 @@ .usage-sparkline { display: block; margin: auto; - color: var(--cudly-primary, #2563eb); + color: var(--cudly-primary); } /* Planned purchases card (issue #753): filter rows of label-above-control * groups, one stat line, and muted footnotes. */ @@ -117,7 +117,7 @@ .ladder-timeline-note { margin: 0 0 var(--cudly-sp-2); font-size: var(--cudly-fs-sm); color: var(--cudly-text-muted); } .ladder-timeline-chart { height: 320px; margin: 1rem 0; } #ladder-purchase-timeline [data-table] { overflow-x: auto; } -.ladder-selected { outline: 2px solid #2563eb; outline-offset: -2px; } +.ladder-selected { outline: 2px solid var(--cudly-primary); outline-offset: -2px; } .ladder-event-editor form { padding: 1rem; } /* "View as table" text alternative under each chart (#744). Collapsed by diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 050e9119..964c5388 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1545,7 +1545,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .cli-command { display: flex; align-items: center; - background: #f8f9fa; + background: var(--cudly-surface-muted); border: 1px solid var(--cudly-border); border-radius: 4px; padding: 0.5rem 0.75rem; diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index c30e3282..679b9852 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -263,7 +263,7 @@ #archera-page-container { position: fixed; inset: 0; - background: #fff; + background: var(--cudly-surface); /* 1100 sits above .modal/.modal-overlay (z-index: 1000) so the education page renders on top of open modals, which is how the overlay gets launched (user clicks the CTA link inside a modal). */ @@ -351,7 +351,7 @@ .archera-signup-btn { display: inline-block; background: #1a73e8; - color: #fff; + color: var(--cudly-primary-fg); text-decoration: none; padding: 0.65rem 1.5rem; border-radius: 4px; @@ -362,7 +362,7 @@ .archera-signup-btn:hover { background: #1557b0; - color: #fff; + color: var(--cudly-primary-fg); text-decoration: none; } @@ -447,7 +447,7 @@ } .archera-offer-skip { - background: #fff; + background: var(--cudly-surface); color: #444; border: 1px solid #d0d7de; border-radius: 4px; @@ -465,7 +465,7 @@ .archera-offer-signup { display: inline-block; background: #1a73e8; - color: #fff; + color: var(--cudly-primary-fg); text-decoration: none; padding: 0.55rem 1.1rem; border-radius: 4px; @@ -476,7 +476,7 @@ .archera-offer-signup:hover { background: #1557b0; - color: #fff; + color: var(--cudly-primary-fg); text-decoration: none; } diff --git a/frontend/src/trends.ts b/frontend/src/trends.ts index 3493aed0..111b102b 100644 --- a/frontend/src/trends.ts +++ b/frontend/src/trends.ts @@ -19,6 +19,7 @@ import type { ApiError } from './api/types'; import * as state from './state'; import { formatCurrency, NO_VALUE } from './utils'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { applyChartDefaults, chartColor } from './lib/chart-theme'; import { DEFAULT_TRENDS_PERIOD_MONTHS, isTrendsPeriodMonths, @@ -270,6 +271,7 @@ function buildTable(id: string, headers: string[], rows: string[][]): HTMLElemen */ function renderChart(canvas: HTMLCanvasElement, series: TrendsMonthPoint[]): void { destroyChart(); + applyChartDefaults(Chart.defaults); trendsChart = new Chart(canvas, { type: 'bar', data: { @@ -277,7 +279,7 @@ function renderChart(canvas: HTMLCanvasElement, series: TrendsMonthPoint[]): voi datasets: [{ label: 'Savings /mo', data: series.map((p) => p.savings), - backgroundColor: '#34a853', + backgroundColor: chartColor('--cudly-chart-2'), borderRadius: 4, maxBarThickness: 48, }], @@ -297,7 +299,7 @@ function renderChart(canvas: HTMLCanvasElement, series: TrendsMonthPoint[]): voi x: { grid: { display: false } }, y: { beginAtZero: true, - grid: { color: 'rgba(0, 0, 0, 0.05)' }, + grid: { color: chartColor('--cudly-chart-grid-faint') }, ticks: { maxTicksLimit: 6, callback: (v) => formatCurrency(Number(v)) }, }, },