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`] = `""`;
+exports[`visualize_data > auto-picks a chart and returns SVG + rationale + alternatives 1`] = `""`;
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`] = `""`;
+exports[`chart render tools > render_area_chart > is deterministic (golden snapshot) 1`] = `""`;
-exports[`chart render tools > render_bar_chart > is deterministic (golden snapshot) 1`] = `""`;
+exports[`chart render tools > render_bar_chart > is deterministic (golden snapshot) 1`] = `""`;
exports[`chart render tools > render_flowchart > is deterministic (golden snapshot) 1`] = `""`;
-exports[`chart render tools > render_line_chart > is deterministic (golden snapshot) 1`] = `""`;
+exports[`chart render tools > render_line_chart > is deterministic (golden snapshot) 1`] = `""`;
-exports[`chart render tools > render_pie_chart > is deterministic (golden snapshot) 1`] = `""`;
+exports[`chart render tools > render_pie_chart > is deterministic (golden snapshot) 1`] = `""`;
-exports[`chart render tools > render_scatter_plot > is deterministic (golden snapshot) 1`] = `""`;
+exports[`chart render tools > render_scatter_plot > is deterministic (golden snapshot) 1`] = `""`;
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`] = `""`;
-exports[`compose_surface > renders a mixed scene (primitive + chart) into one SVG with a shared vibe 1`] = `""`;
+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/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..0ee5b2b
--- /dev/null
+++ b/src/components/a11y.test.ts
@@ -0,0 +1,120 @@
+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..b75404a
--- /dev/null
+++ b/src/core/a11yDescribe.test.ts
@@ -0,0 +1,92 @@
+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..d52ee39
--- /dev/null
+++ b/src/core/a11yDescribe.ts
@@ -0,0 +1,76 @@
+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'}.`;
+}