diff --git a/frontend/src/__tests__/chart-a11y.test.ts b/frontend/src/__tests__/chart-a11y.test.ts
new file mode 100644
index 00000000..60b54449
--- /dev/null
+++ b/frontend/src/__tests__/chart-a11y.test.ts
@@ -0,0 +1,72 @@
+/**
+ * Issue #744: text alternatives for chart canvases.
+ */
+import { setChartSummary, renderChartTable, removeChartTable } from '../lib/chart-a11y';
+
+describe('chart-a11y', () => {
+ let canvas: HTMLCanvasElement;
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ canvas = document.getElementById('c') as HTMLCanvasElement;
+ });
+
+ const data = {
+ caption: 'Savings',
+ columns: ['Service', 'Current', 'Total'],
+ rows: [['compute', '$70', '$210'], ['cache', '$25', '$110']],
+ };
+
+ test('setChartSummary replaces the static aria-label', () => {
+ setChartSummary(canvas, 'Bar chart of 2 services, $320 in total.');
+ expect(canvas.getAttribute('aria-label')).toBe('Bar chart of 2 services, $320 in total.');
+ });
+
+ test('renders a collapsed "View as table" disclosure right after the anchor', () => {
+ renderChartTable(canvas, 't', data);
+ const details = document.getElementById('t') as HTMLDetailsElement;
+ expect(details.tagName).toBe('DETAILS');
+ expect(details.open).toBe(false);
+ expect(canvas.nextElementSibling).toBe(details);
+ expect(details.querySelector('summary')?.textContent).toBe('View as table');
+ expect(details.querySelector('caption')?.textContent).toBe('Savings');
+ });
+
+ test('uses column and row headers so screen readers can announce each cell', () => {
+ renderChartTable(canvas, 't', data);
+ const table = document.querySelector('#t table') as HTMLTableElement;
+ const head = Array.from(table.tHead!.querySelectorAll('th'));
+ expect(head.map((th) => th.textContent)).toEqual(['Service', 'Current', 'Total']);
+ expect(head.every((th) => th.scope === 'col')).toBe(true);
+ const rows = Array.from(table.tBodies[0]!.rows);
+ expect(rows.map((r) => Array.from(r.cells).map((c) => c.textContent))).toEqual(data.rows);
+ for (const r of rows) {
+ expect(r.cells[0]!.tagName).toBe('TH');
+ expect((r.cells[0] as HTMLTableCellElement).scope).toBe('row');
+ expect(r.cells[1]!.tagName).toBe('TD');
+ }
+ });
+
+ test('re-rendering replaces the rows and keeps the open state', () => {
+ renderChartTable(canvas, 't', data);
+ (document.getElementById('t') as HTMLDetailsElement).open = true;
+ renderChartTable(canvas, 't', { ...data, rows: [['rds', '$1', '$2']] });
+ expect(document.querySelectorAll('#t').length).toBe(1);
+ expect(document.querySelectorAll('#t table').length).toBe(1);
+ expect(document.querySelectorAll('#t tbody tr').length).toBe(1);
+ expect((document.getElementById('t') as HTMLDetailsElement).open).toBe(true);
+ });
+
+ test('cell text is never parsed as HTML', () => {
+ renderChartTable(canvas, 't', { ...data, rows: [['
', '$1', '$2']] });
+ expect(document.querySelector('#t img')).toBeNull();
+ expect(document.querySelector('#t tbody th')?.textContent).toBe('
');
+ });
+
+ test('removeChartTable drops the disclosure and tolerates a missing one', () => {
+ renderChartTable(canvas, 't', data);
+ removeChartTable('t');
+ expect(document.getElementById('t')).toBeNull();
+ expect(() => removeChartTable('t')).not.toThrow();
+ });
+});
diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts
index 161f19e5..8996d884 100644
--- a/frontend/src/__tests__/dashboard.test.ts
+++ b/frontend/src/__tests__/dashboard.test.ts
@@ -1058,6 +1058,51 @@ describe('Dashboard Module', () => {
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
});
+ // #744: summary label + table for the trend chart.
+ test('describes the plotted series and lists every point as a table (#744)', async () => {
+ (api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [
+ { timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 },
+ { timestamp: '2026-01-02T00:00:00Z', cumulative_savings: 250 },
+ ] });
+
+ await loadSavingsTrendChart();
+
+ const label = document.getElementById('savings-trend-chart')?.getAttribute('aria-label') ?? '';
+ expect(label).toContain('cumulative savings over the last');
+ expect(label).toContain('$250');
+ expect(label).toContain('2 data points');
+ const rows = document.querySelectorAll('#savings-trend-table tbody tr');
+ expect(rows).toHaveLength(2);
+ expect(rows[1]?.querySelector('td')?.textContent).toBe('$250');
+ });
+
+ // Two points: one point is the sparse-data state (#1334), which draws
+ // no chart and so has no table.
+ const twoTrendPoints = [
+ { timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 },
+ { timestamp: '2026-01-02T00:00:00Z', cumulative_savings: 150 },
+ ];
+
+ test('removes the trend table when there is no data to plot (#744)', async () => {
+ (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: twoTrendPoints });
+ await loadSavingsTrendChart();
+ expect(document.getElementById('savings-trend-table')).not.toBeNull();
+
+ (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [] });
+ await loadSavingsTrendChart();
+ expect(document.getElementById('savings-trend-table')).toBeNull();
+ });
+
+ test('removes the trend table in the sparse-data state (#744, #1334)', async () => {
+ (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: twoTrendPoints });
+ await loadSavingsTrendChart();
+ expect(document.getElementById('savings-trend-table')).not.toBeNull();
+
+ (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [twoTrendPoints[0]] });
+ await loadSavingsTrendChart();
+ expect(document.getElementById('savings-trend-table')).toBeNull();
+ });
+
test('uses daily interval for the default 90-day range', async () => {
await loadSavingsTrendChart();
@@ -1792,6 +1837,67 @@ describe('Dashboard Module', () => {
// renderSavingsByService DOM behaviour tests (now driven by recommendation fixtures).
describe('DOM behaviour', () => {
+ // #744: the role="img" canvas gets a value summary and a table.
+ describe('text alternative (#744)', () => {
+ const tableRows = (): string[][] =>
+ Array.from(document.querySelectorAll('#savings-by-service-table tbody tr'))
+ .map((tr) => Array.from((tr as HTMLTableRowElement).cells).map((c) => c.textContent ?? ''));
+
+ test('summarizes the plotted values in the canvas aria-label', () => {
+ buildDOM();
+ renderSavingsByService(
+ [rec('ec2', 100), rec('ec2', 300, 3), rec('rds', 50)],
+ { ec2: { potential_savings: 300, current_savings: 40 } },
+ );
+ const label = document.getElementById('savings-by-service-chart')?.getAttribute('aria-label') ?? '';
+ // ec2: 40 current + 60 lowest option + 200 upside = 300; rds: 50. Total 350.
+ expect(label).toContain('2 services');
+ expect(label).toContain('$350 in total');
+ expect(label).toContain('Largest: ec2 at $300');
+ });
+
+ test('lists every layer value per service in a "View as table" disclosure', () => {
+ buildDOM();
+ renderSavingsByService(
+ [rec('ec2', 100), rec('ec2', 300, 3), rec('rds', 50)],
+ { ec2: { potential_savings: 300, current_savings: 40 } },
+ );
+ expect(document.querySelector('#savings-by-service-table summary')?.textContent).toBe('View as table');
+ const headers = Array.from(document.querySelectorAll('#savings-by-service-table thead th')).map((th) => th.textContent);
+ expect(headers).toEqual(['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total']);
+ expect(tableRows()).toEqual([
+ ['ec2', '$40', '$60', '$200', '$300'],
+ ['rds', '$0', '$50', '$0', '$50'],
+ ]);
+ });
+
+ test('the table includes services beyond the bars drawn', () => {
+ buildDOM();
+ const many = Array.from({ length: 12 }, (_, i) => rec(`svc-${i}`, 100 + i));
+ renderSavingsByService(many);
+ expect(tableRows()).toHaveLength(12);
+ });
+
+ test('the table is removed when the chart switches to its empty state', () => {
+ buildDOM();
+ renderSavingsByService([rec('ec2', 100)]);
+ expect(document.getElementById('savings-by-service-table')).not.toBeNull();
+ renderSavingsByService([]);
+ expect(document.getElementById('savings-by-service-table')).toBeNull();
+ });
+
+ test('stacked layers are separated by a 2px gap, not only by shade', () => {
+ buildDOM();
+ 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: { borderWidth: { top: number }; borderColor: string }[] } }).data.datasets;
+ for (const d of datasets) {
+ expect(d.borderWidth.top).toBe(2);
+ expect(d.borderColor).toBe('#ffffff');
+ }
+ });
+ });
+
test('shows empty state and hides canvas when no recommendations', () => {
buildDOM();
renderSavingsByService([]);
diff --git a/frontend/src/__tests__/savings-history.test.ts b/frontend/src/__tests__/savings-history.test.ts
index cc787d21..2c95e8ac 100644
--- a/frontend/src/__tests__/savings-history.test.ts
+++ b/frontend/src/__tests__/savings-history.test.ts
@@ -167,6 +167,42 @@ describe('Savings History Module', () => {
expect(Chart).toHaveBeenCalled();
});
+ // #744: the canvas gets a value summary and a "View as table" disclosure
+ // placed after (not inside) the chart's sizing container.
+ test('adds a summary label and a data table for the plotted points (#744)', async () => {
+ (getSavingsAnalytics as jest.Mock).mockResolvedValue({
+ summary: { total_period_savings: 25, total_upfront_spent: 0, purchase_count: 2, average_savings_per_period: 12.5, peak_savings: 15 },
+ data_points: [
+ { timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 0, purchase_count: 1 },
+ { timestamp: '2024-01-02T00:00:00Z', total_savings: 15, cumulative_savings: 25, total_upfront: 0, purchase_count: 1 },
+ ],
+ });
+
+ await loadSavingsHistory();
+
+ const canvas = document.getElementById('savings-history-chart');
+ expect(canvas?.getAttribute('aria-label')).toContain('2 data points');
+ expect(canvas?.getAttribute('aria-label')).toContain('$25.00');
+ const table = document.getElementById('savings-history-table');
+ expect(document.getElementById('savings-history-container')?.nextElementSibling).toBe(table);
+ const headers = Array.from(table?.querySelectorAll('thead th') ?? []).map((th) => th.textContent);
+ expect(headers).toEqual(['Date', 'Period savings (/mo)', 'Cumulative savings']);
+ const last = Array.from(table?.querySelectorAll('tbody tr') ?? []).pop();
+ expect(Array.from(last?.querySelectorAll('td') ?? []).map((td) => td.textContent)).toEqual(['$15.00', '$25.00']);
+ });
+
+ test('removes the data table when the chart is replaced by the empty state (#744)', async () => {
+ (getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({
+ data_points: [{ timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 0, purchase_count: 1 }],
+ });
+ await loadSavingsHistory();
+ expect(document.getElementById('savings-history-table')).not.toBeNull();
+
+ (getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [] });
+ await loadSavingsHistory();
+ expect(document.getElementById('savings-history-table')).toBeNull();
+ });
+
test('shows empty state when no data points', async () => {
(getSavingsAnalytics as jest.Mock).mockResolvedValue({
data_points: []
diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts
index 7e59f371..1e2bb92a 100644
--- a/frontend/src/dashboard.ts
+++ b/frontend/src/dashboard.ts
@@ -15,6 +15,7 @@ import { canAccess } from './permissions';
import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations';
import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton';
import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils';
+import { setChartSummary, renderChartTable, removeChartTable } from './lib/chart-a11y';
// Re-export for backward compatibility (tests and other callers import from dashboard.ts).
export { formatTrendAxisTick } from './modules/chart-utils';
@@ -32,6 +33,14 @@ let savingsByServiceChart: Chart | null = null;
// Maximum number of services to show in the bar chart before truncating.
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;
+
+// Ids of the "View as table" text alternatives for the Home charts (#744).
+const SAVINGS_BY_SERVICE_TABLE_ID = 'savings-by-service-table';
+const SAVINGS_TREND_TABLE_ID = 'savings-trend-table';
+
// One colour per stacked layer, the same on every bar, so the legend swatches
// 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
@@ -895,6 +904,7 @@ export function renderSavingsByService(
if (positive.length === 0) {
if (heading) heading.textContent = 'Potential savings range per service';
canvas.classList.add('hidden');
+ removeChartTable(SAVINGS_BY_SERVICE_TABLE_ID);
if (emptyEl) {
// When a filter is active and excluded all results, surface it so the
// user understands why the chart is blank (mirrors #747 pattern).
@@ -933,6 +943,21 @@ export function renderSavingsByService(
const totalSavings = positive.reduce((acc, e) => acc + e.visibleTotal, 0);
+ // #744: the canvas is role="img", so its label and a table are the only
+ // way a screen-reader user can read these values. Every service is listed,
+ // including any beyond the SAVINGS_BY_SERVICE_MAX bars drawn.
+ const top = positive[0]!;
+ setChartSummary(canvas,
+ `Stacked bar chart of monthly savings for ${positive.length} service${positive.length === 1 ? '' : 's'}, `
+ + `${formatCurrency(totalSavings)} in total. Largest: ${top.svc} at ${formatCurrency(top.visibleTotal)}. `
+ + 'Layers: Current / Committed, Lowest option, Upside. Values are in the table below the chart.');
+ renderChartTable(canvas, SAVINGS_BY_SERVICE_TABLE_ID, {
+ caption: 'Potential savings range per service (monthly)',
+ columns: ['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total'],
+ rows: positive.map(e => [e.svc, formatCurrency(e.current), formatCurrency(e.lowestOption),
+ formatCurrency(e.upside), formatCurrency(e.visibleTotal)]),
+ });
+
savingsByServiceChart = new Chart(canvas, {
type: 'bar',
data: {
@@ -946,6 +971,7 @@ export function renderSavingsByService(
data: currentData,
backgroundColor: SAVINGS_LAYER_COLORS.current,
borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 4 },
+ ...SEGMENT_GAP,
stack: 'savings',
},
{
@@ -953,6 +979,7 @@ export function renderSavingsByService(
data: lowestOptionData,
backgroundColor: SAVINGS_LAYER_COLORS.lowestOption,
borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 },
+ ...SEGMENT_GAP,
stack: 'savings',
},
{
@@ -960,6 +987,7 @@ export function renderSavingsByService(
data: upsideData,
backgroundColor: SAVINGS_LAYER_COLORS.upside,
borderRadius: { topLeft: 4, topRight: 4, bottomLeft: 0, bottomRight: 0 },
+ ...SEGMENT_GAP,
stack: 'savings',
},
],
@@ -1120,6 +1148,7 @@ export async function loadSavingsTrendChart(): Promise {
empty.classList.remove('hidden');
}
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
+ removeChartTable(SAVINGS_TREND_TABLE_ID);
return;
}
// Sparse data (#188): one point would paint a lone dot on a blank canvas,
@@ -1131,6 +1160,9 @@ export async function loadSavingsTrendChart(): Promise {
empty.classList.remove('hidden');
}
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
+ // The sparse message carries the one value; drop any table left from
+ // an earlier render, as the empty state does (#744).
+ removeChartTable(SAVINGS_TREND_TABLE_ID);
return;
}
canvas.classList.remove('hidden');
@@ -1138,6 +1170,19 @@ export async function loadSavingsTrendChart(): Promise {
if (savingsTrendChart) savingsTrendChart.destroy();
+ // #744: summarize the plotted series and list every point as text.
+ const rangeText = isAllRange ? 'the last year' : `the last ${days} days`;
+ const latest = chartData[chartData.length - 1]!;
+ setChartSummary(canvas,
+ `Line chart of cumulative savings over ${rangeText}: ${formatCurrency(latest.y)} `
+ + `as of ${formatTrendAxisTick(latest.x, interval)}, ${chartData.length} data point${chartData.length === 1 ? '' : 's'}. `
+ + 'Values are in the table below the chart.');
+ renderChartTable(canvas, SAVINGS_TREND_TABLE_ID, {
+ caption: `Cumulative savings over ${rangeText}`,
+ columns: ['Date', 'Cumulative savings'],
+ rows: chartData.map(p => [formatTrendAxisTick(p.x, interval), formatCurrency(p.y)]),
+ });
+
savingsTrendChart = new Chart(canvas, {
type: 'line',
data: {
@@ -1193,6 +1238,7 @@ export async function loadSavingsTrendChart(): Promise {
// the dashboard — hide the widget and fall back to a neutral message.
console.warn('Savings trend chart unavailable:', err);
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
+ removeChartTable(SAVINGS_TREND_TABLE_ID);
canvas.classList.add('hidden');
if (empty) {
empty.textContent = 'Savings history is not available yet.';
diff --git a/frontend/src/lib/chart-a11y.ts b/frontend/src/lib/chart-a11y.ts
new file mode 100644
index 00000000..ec99c407
--- /dev/null
+++ b/frontend/src/lib/chart-a11y.ts
@@ -0,0 +1,78 @@
+/**
+ * Text alternatives for Chart.js canvases (issue #744).
+ *
+ * A