From 600493fc56c4168db19c9b627b94d8dab46be127 Mon Sep 17 00:00:00 2001 From: bsevern Date: Thu, 28 May 2026 12:25:38 -0400 Subject: [PATCH 1/2] feat(a11y): fallback generators for core charts (#119) Surface already wires role/title/desc/aria-label; the gap was that charts only emitted when the consumer passed `description`. Adds per-chart-type fallback generators so screen-reader users get a useful one-liner from the data when no description is provided. - new src/core/a11yDescribe.ts: describeBars / describeSeries / describePie / describeScatter - BarChart, LineChart, AreaChart, PieChart, ScatterPlot fall back to the generator when `description` is omitted - new src/components/a11y.test.ts covers role=img, fallback , explicit-description precedence, and aria-label falling back to title - a11yDescribe.test.ts covers each generator (singular nouns, empty data, multi-series, decimal formatting) Bundle stays at 48 KB gzipped (budget 75 KB). All 395 existing tests pass plus 17 new. Follow-up tracked in #119: dataTable forwarding through diagrams (custom DataTableModel per diagram type). --- src/components/AreaChart.tsx | 3 +- src/components/BarChart.tsx | 3 +- src/components/LineChart.tsx | 3 +- src/components/PieChart.tsx | 3 +- src/components/ScatterPlot.tsx | 3 +- src/components/a11y.test.ts | 107 +++++++++++++++++++++++++++++++++ src/core/a11yDescribe.test.ts | 89 +++++++++++++++++++++++++++ src/core/a11yDescribe.ts | 78 ++++++++++++++++++++++++ 8 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/components/a11y.test.ts create mode 100644 src/core/a11yDescribe.test.ts create mode 100644 src/core/a11yDescribe.ts diff --git a/src/components/AreaChart.tsx b/src/components/AreaChart.tsx index 323ab50..3f48f62 100644 --- a/src/components/AreaChart.tsx +++ b/src/components/AreaChart.tsx @@ -8,6 +8,7 @@ import { getPlotArea } from '../core/geometry'; import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { seriesTable } from '../core/dataTable'; +import { describeSeries } from '../core/a11yDescribe'; import { layoutLegend } from '../core/legend'; import type { LegendItem } from '../core/legend'; import { resolveVibe } from '../vibe/resolveVibe'; @@ -173,7 +174,7 @@ export function AreaChart({ vibe={vibe} brand={brand} title={title} - description={description} + description={description ?? describeSeries(series, 'Area')} ariaLabel={ariaLabel} dataTable={dataTable ? seriesTable(series, title) : undefined} className={className} diff --git a/src/components/BarChart.tsx b/src/components/BarChart.tsx index 177181c..4f99571 100644 --- a/src/components/BarChart.tsx +++ b/src/components/BarChart.tsx @@ -12,6 +12,7 @@ import { getPlotArea } from '../core/geometry'; import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { datumTable } from '../core/dataTable'; +import { describeBars } from '../core/a11yDescribe'; import { groupMax, seriesKeysOf, stackLayout, stackMax } from '../core/stack'; import { Surface } from './Surface'; import { Axis } from './Axis'; @@ -215,7 +216,7 @@ export function BarChart({ vibe={vibe} brand={brand} title={title} - description={description} + description={description ?? describeBars(data)} ariaLabel={ariaLabel} dataTable={table} className={className} diff --git a/src/components/LineChart.tsx b/src/components/LineChart.tsx index 643ef4b..1425ca5 100644 --- a/src/components/LineChart.tsx +++ b/src/components/LineChart.tsx @@ -8,6 +8,7 @@ import { getPlotArea } from '../core/geometry'; import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { seriesTable } from '../core/dataTable'; +import { describeSeries } from '../core/a11yDescribe'; import { layoutLegend } from '../core/legend'; import type { LegendItem } from '../core/legend'; import { resolveVibe } from '../vibe/resolveVibe'; @@ -123,7 +124,7 @@ export function LineChart({ vibe={vibe} brand={brand} title={title} - description={description} + description={description ?? describeSeries(series, 'Line')} ariaLabel={ariaLabel} dataTable={dataTable ? seriesTable(series, title) : undefined} className={className} diff --git a/src/components/PieChart.tsx b/src/components/PieChart.tsx index ac6d262..8c7fd9f 100644 --- a/src/components/PieChart.tsx +++ b/src/components/PieChart.tsx @@ -5,6 +5,7 @@ import { computePie } from '../core/arc'; import { colorAt } from '../core/palette'; import { resolveBrand } from '../brand/resolveBrand'; import { datumTable } from '../core/dataTable'; +import { describePie } from '../core/a11yDescribe'; import { resolveVibe } from '../vibe/resolveVibe'; import { Surface } from './Surface'; import { RoughPath } from '../primitives/RoughPath'; @@ -62,7 +63,7 @@ export function PieChart({ vibe={vibe} brand={brand} title={title} - description={description} + description={description ?? describePie(data)} ariaLabel={ariaLabel} dataTable={dataTable ? datumTable(data, title) : undefined} className={className} diff --git a/src/components/ScatterPlot.tsx b/src/components/ScatterPlot.tsx index 8e97583..3d60973 100644 --- a/src/components/ScatterPlot.tsx +++ b/src/components/ScatterPlot.tsx @@ -13,6 +13,7 @@ import { resolveEmphasis } from '../core/emphasis'; import { RoughCircle } from '../primitives/RoughCircle'; import { useVibeContext } from '../vibe/VibeProvider'; import { markAttrs } from '../core/interaction'; +import { describeScatter } from '../core/a11yDescribe'; export interface ScatterDatum { x: number; @@ -103,7 +104,7 @@ export function ScatterPlot({ vibe={vibe} brand={brand} title={title} - description={description} + description={description ?? describeScatter(data)} ariaLabel={ariaLabel} dataTable={ dataTable diff --git a/src/components/a11y.test.ts b/src/components/a11y.test.ts new file mode 100644 index 0000000..dcb0fc5 --- /dev/null +++ b/src/components/a11y.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { BarChart } from './BarChart'; +import { LineChart } from './LineChart'; +import { AreaChart } from './AreaChart'; +import { PieChart } from './PieChart'; +import { ScatterPlot } from './ScatterPlot'; + +const BAR_DATA = [ + { label: 'Q1', value: 12 }, + { label: 'Q2', value: 19 }, +]; +const SERIES_DATA = [{ id: 'a', points: [{ x: 0, y: 1 }, { x: 1, y: 5 }] }]; +const PIE_DATA = [ + { label: 'A', value: 10 }, + { label: 'B', value: 20 }, +]; +const SCATTER_DATA = [{ x: 0, y: 0 }, { x: 1, y: 1 }]; + +const render = (el: ReturnType) => renderToStaticMarkup(el); + +describe('a11y: chart SVGs expose role/title/desc', () => { + it('BarChart emits role=img and a fallback from data when description is omitted', () => { + const svg = render( + createElement(BarChart, { width: 200, height: 100, data: BAR_DATA, bare: true } as any), + ); + expect(svg).toContain('role="img"'); + expect(svg).toMatch(/Bar chart with 2 categories, values from 12 to 19\.<\/desc>/); + }); + + it('BarChart respects an explicit description over the fallback', () => { + const svg = render( + createElement(BarChart, { + width: 200, + height: 100, + data: BAR_DATA, + title: 'Sales', + description: 'Quarterly sales for FY26.', + bare: true, + } as any), + ); + expect(svg).toContain('Sales'); + expect(svg).toContain('Quarterly sales for FY26.'); + }); + + it('LineChart emits a series-aware fallback description', () => { + const svg = render( + createElement(LineChart, { + width: 200, + height: 100, + series: SERIES_DATA, + bare: true, + } as any), + ); + expect(svg).toMatch(/Line chart with 1 series and 2 points, y values from 1 to 5\.<\/desc>/); + }); + + it('AreaChart fallback labels itself as Area', () => { + const svg = render( + createElement(AreaChart, { + width: 200, + height: 100, + series: SERIES_DATA, + bare: true, + } as any), + ); + expect(svg).toMatch(/Area chart with /); + }); + + it('PieChart fallback describes slice count and total', () => { + const svg = render( + createElement(PieChart, { + width: 200, + height: 200, + data: PIE_DATA, + bare: true, + } as any), + ); + expect(svg).toContain('Pie chart with 2 slices totaling 30.'); + }); + + it('ScatterPlot fallback describes point count', () => { + const svg = render( + createElement(ScatterPlot, { + width: 200, + height: 100, + data: SCATTER_DATA, + bare: true, + } as any), + ); + expect(svg).toContain('Scatter plot with 2 points.'); + }); + + it('aria-label falls back to title when ariaLabel is omitted', () => { + const svg = render( + createElement(BarChart, { + width: 200, + height: 100, + data: BAR_DATA, + title: 'Revenue', + bare: true, + } as any), + ); + expect(svg).toContain('aria-label="Revenue"'); + }); +}); diff --git a/src/core/a11yDescribe.test.ts b/src/core/a11yDescribe.test.ts new file mode 100644 index 0000000..f0dae79 --- /dev/null +++ b/src/core/a11yDescribe.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { + describeBars, + describePie, + describeScatter, + describeSeries, +} from './a11yDescribe'; + +describe('a11yDescribe', () => { + it('describes single-series bars with category count and value range', () => { + expect( + describeBars([ + { label: 'Q1', value: 12 }, + { label: 'Q2', value: 19 }, + { label: 'Q3', value: 7 }, + { label: 'Q4', value: 24 }, + ]), + ).toBe('Bar chart with 4 categories, values from 7 to 24.'); + }); + + it('handles a single category (singular noun)', () => { + expect(describeBars([{ label: 'Only', value: 5 }])).toBe( + 'Bar chart with 1 category, value 5.', + ); + }); + + it('describes multi-series bars with series count', () => { + expect( + describeBars([ + { label: 'Q1', values: { a: 1, b: 2 } }, + { label: 'Q2', values: { a: 3, b: 4 } }, + ]), + ).toBe('Bar chart with 2 categories across 2 series, values from 1 to 4.'); + }); + + it('returns a usable string for empty bars', () => { + expect(describeBars([])).toBe('Bar chart with no data.'); + }); + + it('describes line/area series with total point count', () => { + expect( + describeSeries( + [ + { id: 'a', points: [{ x: 0, y: 1 }, { x: 1, y: 5 }] }, + { id: 'b', points: [{ x: 0, y: -3 }, { x: 1, y: 2 }] }, + ], + 'Line', + ), + ).toBe('Line chart with 2 series and 4 points, y values from -3 to 5.'); + }); + + it('respects the Area kind label', () => { + expect( + describeSeries([{ id: 'a', points: [{ x: 0, y: 1 }] }], 'Area'), + ).toBe('Area chart with 1 series and 1 point, y value 1.'); + }); + + it('describes pies with slice count and total', () => { + expect( + describePie([ + { label: 'A', value: 30 }, + { label: 'B', value: 20 }, + { label: 'C', value: 10 }, + ]), + ).toBe('Pie chart with 3 slices totaling 60.'); + }); + + it('formats non-integer values with up to two decimals, trimmed', () => { + expect(describePie([{ label: 'A', value: 1.5 }])).toBe( + 'Pie chart with 1 slice totaling 1.5.', + ); + }); + + it('describes scatter data with point count', () => { + expect( + describeScatter([ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 4 }, + ]), + ).toBe('Scatter plot with 3 points.'); + }); + + it('handles empty inputs across all generators', () => { + expect(describeSeries([])).toBe('Line chart with no data.'); + expect(describePie([])).toBe('Pie chart with no data.'); + expect(describeScatter([])).toBe('Scatter plot with no data.'); + }); +}); diff --git a/src/core/a11yDescribe.ts b/src/core/a11yDescribe.ts new file mode 100644 index 0000000..b75760e --- /dev/null +++ b/src/core/a11yDescribe.ts @@ -0,0 +1,78 @@ +import type { ChartDatum, MultiSeriesDatum, Series } from '../types/charts'; + +/** + * Fallback `` text generators. Each returns a short sentence summarising + * the chart's shape so screen-reader users get *something* useful when a + * consumer doesn't supply an explicit `description`. Kept deterministic and + * dependency-free; charts call these in the same expression that passes + * `description` to ``. + */ + +function isMultiSeries( + data: ChartDatum[] | MultiSeriesDatum[], +): data is MultiSeriesDatum[] { + return data.length > 0 && 'values' in (data[0] as object); +} + +function fmt(n: number): string { + if (!Number.isFinite(n)) return String(n); + if (Number.isInteger(n)) return String(n); + return n.toFixed(2).replace(/\.?0+$/, ''); +} + +function rangeOf(values: number[]): string { + if (values.length === 0) return ''; + let min = values[0]; + let max = values[0]; + for (const v of values) { + if (v < min) min = v; + if (v > max) max = v; + } + return min === max ? `value ${fmt(min)}` : `values from ${fmt(min)} to ${fmt(max)}`; +} + +export function describeBars(data: ChartDatum[] | MultiSeriesDatum[]): string { + if (data.length === 0) return 'Bar chart with no data.'; + if (isMultiSeries(data)) { + const seriesKeys = new Set(); + const allValues: number[] = []; + for (const d of data) { + for (const k of Object.keys(d.values)) seriesKeys.add(k); + for (const v of Object.values(d.values)) allValues.push(v); + } + return ( + `Bar chart with ${data.length} categor${data.length === 1 ? 'y' : 'ies'} ` + + `across ${seriesKeys.size} series, ${rangeOf(allValues)}.` + ); + } + const single = data as ChartDatum[]; + return ( + `Bar chart with ${single.length} categor${single.length === 1 ? 'y' : 'ies'}, ` + + `${rangeOf(single.map((d) => d.value))}.` + ); +} + +export function describeSeries(series: Series[], kind: 'Line' | 'Area' = 'Line'): string { + if (series.length === 0) return `${kind} chart with no data.`; + const total = series.reduce((s, d) => s + d.points.length, 0); + const ys = series.flatMap((s) => s.points.map((p) => p.y)); + return ( + `${kind} chart with ${series.length} series and ${total} ` + + `point${total === 1 ? '' : 's'}, y ${rangeOf(ys)}.` + ); +} + +export function describePie(data: ChartDatum[]): string { + if (data.length === 0) return 'Pie chart with no data.'; + const total = data.reduce((s, d) => s + d.value, 0); + return ( + `Pie chart with ${data.length} slice${data.length === 1 ? '' : 's'} ` + + `totaling ${fmt(total)}.` + ); +} + +/** Scatter takes a flat point array (no series wrapper); describes count only. */ +export function describeScatter(data: { x: number; y: number }[]): string { + if (data.length === 0) return 'Scatter plot with no data.'; + return `Scatter plot with ${data.length} point${data.length === 1 ? '' : 's'}.`; +} From a875e355073f9d1aff2c4168a6ff55e60deb5a61 Mon Sep 17 00:00:00 2001 From: bsevern Date: Thu, 28 May 2026 12:30:43 -0400 Subject: [PATCH 2/2] fix: prettier format + refresh mcp snapshots for fallback --- .../__snapshots__/chartFeatures.test.ts.snap | 2 +- mcp/src/__snapshots__/charts.test.ts.snap | 10 ++--- .../orchestrationTools.test.ts.snap | 2 +- src/components/a11y.test.ts | 19 ++++++++-- src/core/a11yDescribe.test.ts | 37 ++++++++++--------- src/core/a11yDescribe.ts | 4 +- 6 files changed, 44 insertions(+), 30 deletions(-) diff --git a/mcp/src/__snapshots__/chartFeatures.test.ts.snap b/mcp/src/__snapshots__/chartFeatures.test.ts.snap index 1244550..51a6126 100644 --- a/mcp/src/__snapshots__/chartFeatures.test.ts.snap +++ b/mcp/src/__snapshots__/chartFeatures.test.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`visualize_data > auto-picks a chart and returns SVG + rationale + alternatives 1`] = `"sales by regionNAEUAPAC01234567"`; +exports[`visualize_data > auto-picks a chart and returns SVG + rationale + alternatives 1`] = `"sales by regionBar chart with 3 categories, values from 3 to 7.NAEUAPAC01234567"`; diff --git a/mcp/src/__snapshots__/charts.test.ts.snap b/mcp/src/__snapshots__/charts.test.ts.snap index d346b8b..f589dd4 100644 --- a/mcp/src/__snapshots__/charts.test.ts.snap +++ b/mcp/src/__snapshots__/charts.test.ts.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`chart render tools > render_area_chart > is deterministic (golden snapshot) 1`] = `"00.511.5201234"`; +exports[`chart render tools > render_area_chart > is deterministic (golden snapshot) 1`] = `"Area chart with 1 series and 3 points, y values from 1 to 4.00.511.5201234"`; -exports[`chart render tools > render_bar_chart > is deterministic (golden snapshot) 1`] = `"ab0123456"`; +exports[`chart render tools > render_bar_chart > is deterministic (golden snapshot) 1`] = `"Bar chart with 2 categories, values from 3 to 6.ab0123456"`; exports[`chart render tools > render_flowchart > is deterministic (golden snapshot) 1`] = `"StartEnd"`; -exports[`chart render tools > render_line_chart > is deterministic (golden snapshot) 1`] = `"00.511.5201234"`; +exports[`chart render tools > render_line_chart > is deterministic (golden snapshot) 1`] = `"Line chart with 1 series and 3 points, y values from 1 to 4.00.511.5201234"`; -exports[`chart render tools > render_pie_chart > is deterministic (golden snapshot) 1`] = `"ab"`; +exports[`chart render tools > render_pie_chart > is deterministic (golden snapshot) 1`] = `"Pie chart with 2 slices totaling 3.ab"`; -exports[`chart render tools > render_scatter_plot > is deterministic (golden snapshot) 1`] = `"11.522.5322.533.54"`; +exports[`chart render tools > render_scatter_plot > is deterministic (golden snapshot) 1`] = `"Scatter plot with 2 points.11.522.5322.533.54"`; diff --git a/mcp/src/__snapshots__/orchestrationTools.test.ts.snap b/mcp/src/__snapshots__/orchestrationTools.test.ts.snap index 8224dee..b3da929 100644 --- a/mcp/src/__snapshots__/orchestrationTools.test.ts.snap +++ b/mcp/src/__snapshots__/orchestrationTools.test.ts.snap @@ -2,4 +2,4 @@ exports[`build_flowchart_from_spec > auto-assigns node shapes and renders a flowchart 1`] = `"StartReady?Go"`; -exports[`compose_surface > renders a mixed scene (primitive + chart) into one SVG with a shared vibe 1`] = `"Dashboardab0123456xy"`; +exports[`compose_surface > renders a mixed scene (primitive + chart) into one SVG with a shared vibe 1`] = `"DashboardBar chart with 2 categories, values from 3 to 6.ab0123456Pie chart with 2 slices totaling 3.xy"`; diff --git a/src/components/a11y.test.ts b/src/components/a11y.test.ts index dcb0fc5..0ee5b2b 100644 --- a/src/components/a11y.test.ts +++ b/src/components/a11y.test.ts @@ -11,12 +11,23 @@ const BAR_DATA = [ { label: 'Q1', value: 12 }, { label: 'Q2', value: 19 }, ]; -const SERIES_DATA = [{ id: 'a', points: [{ x: 0, y: 1 }, { x: 1, y: 5 }] }]; +const SERIES_DATA = [ + { + id: 'a', + points: [ + { x: 0, y: 1 }, + { x: 1, y: 5 }, + ], + }, +]; const PIE_DATA = [ { label: 'A', value: 10 }, { label: 'B', value: 20 }, ]; -const SCATTER_DATA = [{ x: 0, y: 0 }, { x: 1, y: 1 }]; +const SCATTER_DATA = [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, +]; const render = (el: ReturnType) => renderToStaticMarkup(el); @@ -53,7 +64,9 @@ describe('a11y: chart SVGs expose role/title/desc', () => { bare: true, } as any), ); - expect(svg).toMatch(/Line chart with 1 series and 2 points, y values from 1 to 5\.<\/desc>/); + expect(svg).toMatch( + /Line chart with 1 series and 2 points, y values from 1 to 5\.<\/desc>/, + ); }); it('AreaChart fallback labels itself as Area', () => { diff --git a/src/core/a11yDescribe.test.ts b/src/core/a11yDescribe.test.ts index f0dae79..b75404a 100644 --- a/src/core/a11yDescribe.test.ts +++ b/src/core/a11yDescribe.test.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - describeBars, - describePie, - describeScatter, - describeSeries, -} from './a11yDescribe'; +import { describeBars, describePie, describeScatter, describeSeries } from './a11yDescribe'; describe('a11yDescribe', () => { it('describes single-series bars with category count and value range', () => { @@ -19,9 +14,7 @@ describe('a11yDescribe', () => { }); it('handles a single category (singular noun)', () => { - expect(describeBars([{ label: 'Only', value: 5 }])).toBe( - 'Bar chart with 1 category, value 5.', - ); + expect(describeBars([{ label: 'Only', value: 5 }])).toBe('Bar chart with 1 category, value 5.'); }); it('describes multi-series bars with series count', () => { @@ -41,8 +34,20 @@ describe('a11yDescribe', () => { expect( describeSeries( [ - { id: 'a', points: [{ x: 0, y: 1 }, { x: 1, y: 5 }] }, - { id: 'b', points: [{ x: 0, y: -3 }, { x: 1, y: 2 }] }, + { + id: 'a', + points: [ + { x: 0, y: 1 }, + { x: 1, y: 5 }, + ], + }, + { + id: 'b', + points: [ + { x: 0, y: -3 }, + { x: 1, y: 2 }, + ], + }, ], 'Line', ), @@ -50,9 +55,9 @@ describe('a11yDescribe', () => { }); it('respects the Area kind label', () => { - expect( - describeSeries([{ id: 'a', points: [{ x: 0, y: 1 }] }], 'Area'), - ).toBe('Area chart with 1 series and 1 point, y value 1.'); + expect(describeSeries([{ id: 'a', points: [{ x: 0, y: 1 }] }], 'Area')).toBe( + 'Area chart with 1 series and 1 point, y value 1.', + ); }); it('describes pies with slice count and total', () => { @@ -66,9 +71,7 @@ describe('a11yDescribe', () => { }); it('formats non-integer values with up to two decimals, trimmed', () => { - expect(describePie([{ label: 'A', value: 1.5 }])).toBe( - 'Pie chart with 1 slice totaling 1.5.', - ); + expect(describePie([{ label: 'A', value: 1.5 }])).toBe('Pie chart with 1 slice totaling 1.5.'); }); it('describes scatter data with point count', () => { diff --git a/src/core/a11yDescribe.ts b/src/core/a11yDescribe.ts index b75760e..d52ee39 100644 --- a/src/core/a11yDescribe.ts +++ b/src/core/a11yDescribe.ts @@ -8,9 +8,7 @@ import type { ChartDatum, MultiSeriesDatum, Series } from '../types/charts'; * `description` to ``. */ -function isMultiSeries( - data: ChartDatum[] | MultiSeriesDatum[], -): data is MultiSeriesDatum[] { +function isMultiSeries(data: ChartDatum[] | MultiSeriesDatum[]): data is MultiSeriesDatum[] { return data.length > 0 && 'values' in (data[0] as object); }