From 3ffc3b7bdd54915e1dfb003660ea168e01426196 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:53:14 +0000 Subject: [PATCH 1/5] refactor(frontend): read chart colors from design tokens Chart.js paints on canvas, so the dashboard and savings-history charts hard-coded their hex colors and no stylesheet could restyle them: a theme or contrast fix could reach every surface except the charts. Add --cudly-chart-* tokens (series palette, cumulative line, legend swatches, axis text and gridlines) to the :root block in base.css, with the same values the charts used, and lib/chart-theme.ts to read them at render time (falling back to the same values when the stylesheet is absent, e.g. under jsdom). The dashboard per-service bars, legend and savings trend, and the savings-history chart now take every color from it, and Chart.js's global text/grid defaults follow the tokens too. The ladder chart's existing token reader moves into the same module. Also swap the stylesheet literals that already equal a token's value (#fff surfaces and button text, #f8f9fa, #2563eb) for the token. With no token values changed, every chart and surface renders pixel-identical. New tests keep the TypeScript fallbacks in sync with base.css, keep the chart modules free of color literals, and cap the remaining stylesheet color literals at today's 139 so the count can only go down. This is the theming groundwork from the issue. Migrating the remaining one-off literals and adding a prefers-color-scheme dark palette are left for follow-ups that need palette decisions. Refs #699 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-theme.test.ts | 112 +++++++++++++++++++++ frontend/src/dashboard.ts | 64 +++++------- frontend/src/ladder-timeline-chart.ts | 8 +- frontend/src/lib/chart-theme.ts | 111 ++++++++++++++++++++ frontend/src/modules/savings-history.ts | 14 ++- frontend/src/styles/base.css | 22 ++++ frontend/src/styles/charts.css | 4 +- frontend/src/styles/components.css | 2 +- frontend/src/styles/modals.css | 14 +-- 9 files changed, 289 insertions(+), 62 deletions(-) create mode 100644 frontend/src/__tests__/chart-theme.test.ts create mode 100644 frontend/src/lib/chart-theme.ts diff --git a/frontend/src/__tests__/chart-theme.test.ts b/frontend/src/__tests__/chart-theme.test.ts new file mode 100644 index 000000000..d2fe0a565 --- /dev/null +++ b/frontend/src/__tests__/chart-theme.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for chart colors from design tokens (issue #699). + */ +import fs from 'fs'; +import path from 'path'; +import { + CHART_TOKEN_FALLBACKS, applyChartDefaults, chartColor, chartPalette, requireToken, 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('series and legend 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 { serviceBarColor } = require('../dashboard') as { serviceBarColor: (s: string) => string }; + expect(serviceBarColor('ec2')).toBe('#1a73e8'); + document.documentElement.style.setProperty('--cudly-chart-1', '#8ab4f8'); + expect(serviceBarColor('ec2')).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'])('%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. + // 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 = 139; + + 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/dashboard.ts b/frontend/src/dashboard.ts index 2a8e61716..0cd4faf33 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -14,6 +14,7 @@ import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; import { formatTrendAxisTick } from './modules/chart-utils'; +import { applyChartDefaults, chartColor, chartPalette, parseHexColor, withAlpha } from './lib/chart-theme'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). export { formatTrendAxisTick } from './modules/chart-utils'; @@ -31,20 +32,9 @@ let savingsByServiceChart: Chart | null = null; // Maximum number of services to show in the bar chart before truncating. const SAVINGS_BY_SERVICE_MAX = 10; -// Palette for per-service bars. A service's colour is fixed by its name (see +// Per-service bars use the --cudly-chart-1..10 token palette (issue #699, +// lib/chart-theme.ts). A service's colour is fixed by its name (see // serviceBarColor), never by its rank, so it survives data refreshes (#700). -const SERVICE_BAR_COLORS = [ - '#1a73e8', // blue - '#34a853', // green - '#fbbc04', // yellow - '#ea4335', // red - '#9c27b0', // purple - '#00bcd4', // cyan - '#ff5722', // deep-orange - '#607d8b', // blue-grey - '#795548', // brown - '#e91e63', // pink -]; // One palette slot per service family, keyed on the slugs the app emits // (purchase mapServiceSlug, plan-service-ids.ts). A canonical slug and its @@ -65,38 +55,29 @@ const KNOWN_SERVICE_SLOTS = 7; /** Stable colour for a service name: identical on every render, regardless of rank. */ export function serviceBarColor(service: string): string { const key = service.toLowerCase(); + const palette = chartPalette(); let idx = KNOWN_SERVICE_COLOR_INDEX[key]; if (idx === undefined) { // Unknown slugs hash into the spare slots; two unknown slugs may collide. let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; - idx = KNOWN_SERVICE_SLOTS + (h % (SERVICE_BAR_COLORS.length - KNOWN_SERVICE_SLOTS)); + idx = KNOWN_SERVICE_SLOTS + (h % (palette.length - KNOWN_SERVICE_SLOTS)); } - return SERVICE_BAR_COLORS[idx] ?? '#1a73e8'; + return palette[idx] ?? chartColor('--cudly-chart-1'); } // The legend names the three stacked layers, which every service shares, so // its swatches are neutral tones that mirror the bar shading (dark / solid / // 35% alpha) instead of the first bar's service colour. -const LEGEND_SWATCH_COLORS = ['#37474f', '#78909c', 'rgba(120,144,156,0.35)']; -// Border keeps the pale Upside swatch at >= 3:1 against the white card. -const LEGEND_SWATCH_BORDER = '#546e7a'; - -/** - * Parse a #rrggbb hex string into its r/g/b components. Falls back to the - * default service bar blue (#1a73e8) for malformed input so the chart never - * renders a NaN colour. Exported for unit testing. - */ -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]! : '1a73e8'; - return { - r: parseInt(clean.substring(0, 2), 16), - g: parseInt(clean.substring(2, 4), 16), - b: parseInt(clean.substring(4, 6), 16), - }; +function legendSwatchColors(): string[] { + const mid = chartColor('--cudly-chart-legend-mid'); + return [chartColor('--cudly-chart-legend-dark'), mid, withAlpha(mid, 0.35)]; } +// parseHexColor moved to lib/chart-theme.ts (issue #699); re-exported here +// for existing callers and tests. +export { parseHexColor }; + /** * Derive a darker shade of a base #rrggbb colour by reducing its perceived * lightness. Used for the current-savings underlay so it reads as the @@ -995,11 +976,12 @@ export function renderSavingsByService( const lowestOptionColors = bgColors; // Upside: same hue at 35% opacity for the variability band. - const upsideColors = bgColors.map(c => { - const { r, g, b } = parseHexColor(c); - return `rgba(${r},${g},${b},0.35)`; - }); + const upsideColors = bgColors.map(c => withAlpha(c, 0.35)); + const legendColors = legendSwatchColors(); + // Border keeps the pale Upside swatch at >= 3:1 against the white card. + const legendBorder = chartColor('--cudly-chart-legend-border'); + applyChartDefaults(Chart.defaults); savingsByServiceChart = new Chart(canvas, { type: 'bar', data: { @@ -1042,8 +1024,8 @@ export function renderSavingsByService( boxWidth: 12, generateLabels: (chart) => chart.data.datasets.map((ds, i) => ({ text: ds.label ?? '', - fillStyle: LEGEND_SWATCH_COLORS[i] ?? LEGEND_SWATCH_COLORS[1] ?? '#78909c', - strokeStyle: LEGEND_SWATCH_BORDER, + fillStyle: legendColors[i] ?? legendColors[1], + strokeStyle: legendBorder, lineWidth: 1, hidden: !chart.isDatasetVisible(i), datasetIndex: i, @@ -1206,14 +1188,16 @@ export async function loadSavingsTrendChart(): Promise { if (savingsTrendChart) savingsTrendChart.destroy(); + const trendColor = chartColor('--cudly-chart-1'); + applyChartDefaults(Chart.defaults); savingsTrendChart = new Chart(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, diff --git a/frontend/src/ladder-timeline-chart.ts b/frontend/src/ladder-timeline-chart.ts index d79d90f6c..898ae340a 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 000000000..ebfdc6788 --- /dev/null +++ b/frontend/src/lib/chart-theme.ts @@ -0,0 +1,111 @@ +/** + * 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 legend tokens must be #rrggbb: the charts derive darker and + * translucent shades from them (see withAlpha and darkenHexColor). + */ + +export const CHART_TOKEN_FALLBACKS = { + // Per-service bar palette. A service keeps its slot across refreshes + // (dashboard.serviceBarColor), so reordering these recolors services. + '--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', + // Neutral legend swatches for the stacked savings-range layers. + '--cudly-chart-legend-dark': '#37474f', + '--cudly-chart-legend-mid': '#78909c', + '--cudly-chart-legend-border': '#546e7a', + // 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 per-service 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'); +} diff --git a/frontend/src/modules/savings-history.ts b/frontend/src/modules/savings-history.ts index 60a4332f5..752acc2fd 100644 --- a/frontend/src/modules/savings-history.ts +++ b/frontend/src/modules/savings-history.ts @@ -6,6 +6,7 @@ import { Chart, registerables } from 'chart.js'; import { getSavingsAnalytics, type SavingsAnalyticsResponse, type SavingsDataPoint } from '../api'; import * as state from '../state'; import { formatTrendAxisTick } from './chart-utils'; +import { applyChartDefaults, chartColor, withAlpha } from '../lib/chart-theme'; // Register Chart.js components Chart.register(...registerables); @@ -401,6 +402,9 @@ function renderSavingsChart( savingsChart.destroy(); } + const periodColor = chartColor('--cudly-chart-2'); + const cumulativeColor = chartColor('--cudly-chart-cumulative'); + applyChartDefaults(Chart.defaults); savingsChart = new Chart(ctx, { type: 'line', data: { @@ -408,8 +412,8 @@ function renderSavingsChart( { label: 'Period Savings', data: periodSavingsData, - borderColor: '#34a853', - backgroundColor: 'rgba(52, 168, 83, 0.1)', + borderColor: periodColor, + backgroundColor: withAlpha(periodColor, 0.1), fill: true, tension: 0.3, pointRadius: dataPoints.length > 50 ? 0 : 3, @@ -419,8 +423,8 @@ function renderSavingsChart( { label: 'Cumulative Savings', data: cumulativeSavingsData, - borderColor: '#4285f4', - backgroundColor: 'rgba(66, 133, 244, 0.05)', + borderColor: cumulativeColor, + backgroundColor: withAlpha(cumulativeColor, 0.05), fill: false, tension: 0.3, borderDash: [5, 5], @@ -458,7 +462,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 74c206fbd..c8e5caedf 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -80,6 +80,28 @@ --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 legend 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; + --cudly-chart-legend-dark: #37474f; + --cudly-chart-legend-mid: #78909c; + --cudly-chart-legend-border: #546e7a; + --cudly-chart-text: #666666; + --cudly-chart-grid: rgba(0, 0, 0, 0.1); + --cudly-chart-grid-faint: rgba(0, 0, 0, 0.05); + /* Layout — used by the action-center shell (top bar + sidebar) */ --cudly-topbar-h: 56px; --cudly-sidebar-w: 232px; diff --git a/frontend/src/styles/charts.css b/frontend/src/styles/charts.css index f63828eac..62722c2c4 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); } .ladder-timeline-controls { display: flex; @@ -104,5 +104,5 @@ .ladder-timeline-controls label { display: grid; gap: 0.25rem; } .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; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 5d76ec88e..444f12573 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1534,7 +1534,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 ac40fa77d..ae97416a7 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -243,7 +243,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). */ @@ -331,7 +331,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; @@ -342,7 +342,7 @@ .archera-signup-btn:hover { background: #1557b0; - color: #fff; + color: var(--cudly-primary-fg); text-decoration: none; } @@ -377,7 +377,7 @@ .archera-offer-panel { position: relative; - background: #fff; + background: var(--cudly-surface); border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); max-width: 440px; @@ -427,7 +427,7 @@ } .archera-offer-skip { - background: #fff; + background: var(--cudly-surface); color: #444; border: 1px solid #d0d7de; border-radius: 4px; @@ -445,7 +445,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; @@ -456,7 +456,7 @@ .archera-offer-signup:hover { background: #1557b0; - color: #fff; + color: var(--cudly-primary-fg); text-decoration: none; } From f4e84397bb3344114262b45149e7677d61e75529 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 16:48:06 +0000 Subject: [PATCH 2/5] test(frontend): lower the CSS color-literal ceiling to 136 after merging main Main's CSS tidy-ups since a14dc8f2 removed three more literals, so the ratchet drops from 139 to the current count. Refs #699 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-theme.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/__tests__/chart-theme.test.ts b/frontend/src/__tests__/chart-theme.test.ts index 4bab71549..4d509fd93 100644 --- a/frontend/src/__tests__/chart-theme.test.ts +++ b/frontend/src/__tests__/chart-theme.test.ts @@ -102,7 +102,7 @@ describe('color literals (theming ratchet)', () => { // Stylesheet literals bypass the tokens, so a theme cannot reach them. // 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 = 139; + const CSS_LITERAL_CEILING = 136; it(`styles/*.css hold at most ${CSS_LITERAL_CEILING} color literals outside token definitions`, () => { const counts = fs.readdirSync(STYLES) From 21d231b6ac7904bbc1d69062fefbf217766b173e Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 16:49:03 +0000 Subject: [PATCH 3/5] fix(frontend): read the Trends chart colors from design tokens The Trends sub-tab chart that landed on main (#1317) hard-coded its bar and gridline colors. They already equal --cudly-chart-2 and --cudly-chart-grid-faint, so it now reads those tokens (and the shared Chart.js text/grid defaults) like the other charts, rendering identically, and the no-color-literals test covers trends.ts. Refs #699 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-theme.test.ts | 2 +- frontend/src/trends.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/chart-theme.test.ts b/frontend/src/__tests__/chart-theme.test.ts index 4d509fd93..14af764bd 100644 --- a/frontend/src/__tests__/chart-theme.test.ts +++ b/frontend/src/__tests__/chart-theme.test.ts @@ -90,7 +90,7 @@ describe('chart-theme', () => { 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'])('%s has no color literals', (file) => { + 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)) diff --git a/frontend/src/trends.ts b/frontend/src/trends.ts index 34bce6750..12306d404 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 } from './utils'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { applyChartDefaults, chartColor } from './lib/chart-theme'; import { DEFAULT_TRENDS_PERIOD_MONTHS, isTrendsPeriodMonths, @@ -266,6 +267,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: { @@ -273,7 +275,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, }], @@ -293,7 +295,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)) }, }, }, From 5ab3ad41f77b00bd362b8f6b02d8db5003c7a379 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 23:53:48 +0000 Subject: [PATCH 4/5] fix(frontend): read the stacked-bar segment gap from --cudly-surface Main's #744 gave the per-service savings chart a 2px '#ffffff' gap between stacked layers, a color literal in dashboard.ts that the chart-theme guard rejects and that a theme could not reach. The gap is meant to read as the card background, so it now uses the --cudly-surface token (#ffffff, so the chart renders the same) through a new surfaceColor() in lib/chart-theme.ts, read at render time like the other chart colors. The fallback for jsdom or a failed stylesheet load equals base.css, and a test keeps the two in sync. Refs #699 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-theme.test.ts | 11 +++++++++-- frontend/src/__tests__/dashboard.test.ts | 13 +++++++++++++ frontend/src/dashboard.ts | 16 ++++++++++------ frontend/src/lib/chart-theme.ts | 11 +++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/chart-theme.test.ts b/frontend/src/__tests__/chart-theme.test.ts index 14af764bd..99dae1459 100644 --- a/frontend/src/__tests__/chart-theme.test.ts +++ b/frontend/src/__tests__/chart-theme.test.ts @@ -4,8 +4,8 @@ import fs from 'fs'; import path from 'path'; import { - CHART_TOKEN_FALLBACKS, applyChartDefaults, chartColor, chartPalette, requireToken, withAlpha, - type ChartToken, + CHART_TOKEN_FALLBACKS, SURFACE_FALLBACK, applyChartDefaults, chartColor, chartPalette, requireToken, + surfaceColor, withAlpha, type ChartToken, } from '../lib/chart-theme'; const SRC = path.join(__dirname, '..'); @@ -44,6 +44,13 @@ describe('chart-theme', () => { 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; diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 22765cf26..f8faf0022 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -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', () => { diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 61e4d20d7..64d46a8e7 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -21,7 +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, withAlpha } from './lib/chart-theme'; +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'; @@ -42,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'; @@ -1025,6 +1028,7 @@ export function renderSavingsByService( }); 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, { @@ -1040,7 +1044,7 @@ export function renderSavingsByService( data: currentData, backgroundColor: layerColors.current, borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 4 }, - ...SEGMENT_GAP, + ...gap, stack: 'savings', }, { @@ -1048,7 +1052,7 @@ export function renderSavingsByService( data: lowestOptionData, backgroundColor: layerColors.lowestOption, borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 }, - ...SEGMENT_GAP, + ...gap, stack: 'savings', }, { @@ -1056,7 +1060,7 @@ export function renderSavingsByService( data: upsideData, backgroundColor: layerColors.upside, borderRadius: { topLeft: 4, topRight: 4, bottomLeft: 0, bottomRight: 0 }, - ...SEGMENT_GAP, + ...gap, stack: 'savings', }, ], diff --git a/frontend/src/lib/chart-theme.ts b/frontend/src/lib/chart-theme.ts index 7489e861f..3a4ceb563 100644 --- a/frontend/src/lib/chart-theme.ts +++ b/frontend/src/lib/chart-theme.ts @@ -110,3 +110,14 @@ export function applyChartDefaults(defaults: ChartDefaultsLike | undefined): voi 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; +} From 4677d7cb6dc76dae982db84a3bb869c161bbcf2b Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 23:53:52 +0000 Subject: [PATCH 5/5] test(frontend): set the CSS color-literal ceiling to 113, main's count This PR lands after the other UI PRs, so the ratchet's baseline is the count on main at that point. Merged with main at b42245cc, styles/*.css hold 113 color literals outside token definitions (components 43, modals 29, settings 14, responsive 13, tables 8, layout 4, forms 2), down from 136. The comment now says the baseline is the count when the ratchet landed. Refs #699 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-theme.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/chart-theme.test.ts b/frontend/src/__tests__/chart-theme.test.ts index 99dae1459..2579d099a 100644 --- a/frontend/src/__tests__/chart-theme.test.ts +++ b/frontend/src/__tests__/chart-theme.test.ts @@ -107,9 +107,10 @@ describe('color literals (theming ratchet)', () => { }); // Stylesheet literals bypass the tokens, so a theme cannot reach them. - // 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 = 136; + // 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)