From a8f4c72d18efc68b65b02f487acebad872c573d8 Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sat, 18 Jul 2026 23:30:08 +0800 Subject: [PATCH] feat(a11y): compiler-generated chart descriptions + ECharts aria/decal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate a short accessible description from the semantic layer at compile time (chart type, measure with unit, dimension, series, category/series counts, value range — structural and statistical only, no interpretive claims) and emit it on every backend: Vega-Lite top-level description, ECharts aria.label via the built-in aria module (enabled by default), and _a11y.description metadata for Chart.js hosts to wire onto the canvas aria-label. field_display_names is respected. ECharts decal patterns (color-vision-deficiency support) are available behind options.a11yDecal — opt-in because they visibly change the chart, while the description metadata is invisible and always on. - core/a11y-description.ts: deterministic description builder - AssembleOptions.a11yDecal knob (ECharts only) - tests: 11 cases (copy exactness, grouped series, display names, determinism, per-backend surfaces, decal gating, JSON purity); no existing test expectations changed - docs/accessibility.md + api-reference link (includes the Chart.js wiring note and a Chartability coverage statement) Refs #48 Co-Authored-By: Claude Fable 5 --- docs/accessibility.md | 69 ++++++++ docs/api-reference.md | 1 + packages/flint-js/src/chartjs/assemble.ts | 13 ++ .../flint-js/src/core/a11y-description.ts | 153 ++++++++++++++++++ packages/flint-js/src/core/types.ts | 8 + packages/flint-js/src/echarts/assemble.ts | 19 +++ packages/flint-js/src/vegalite/assemble.ts | 10 ++ packages/flint-js/tests/a11y-baseline.test.ts | 131 +++++++++++++++ 8 files changed, 404 insertions(+) create mode 100644 docs/accessibility.md create mode 100644 packages/flint-js/src/core/a11y-description.ts create mode 100644 packages/flint-js/tests/a11y-baseline.test.ts diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 0000000..118d218 --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,69 @@ +# Accessibility + +Flint sits at the compile step, so accessibility surfaces can be generated +once from the semantic layer and land in every backend consistently — +designed in rather than patched on per chart. + +## What is emitted by default + +Every compiled chart carries a short generated description built from the +resolved semantics and the data: chart type, measure (with unit), dimension, +series, category/series counts, and the value range. The copy is structural +and statistical only — Flint never emits perceptual or interpretive claims +("X is trending up") it cannot verify. + +Example, for a bar chart of `revenue` (annotated `Price` + `unit: "USD"`) by +`region`: + +``` +Bar Chart of revenue (USD) by region. 3 categories. Range 145–168. +``` + +Per backend: + +| Backend | Surface | +|---|---| +| Vega-Lite | Top-level `description` (rendered as the SVG `aria-label`) | +| ECharts | `aria: { enabled: true, label: { description } }` (built-in aria module) | +| Chart.js | `_a11y.description` metadata — see wiring note below | + +`field_display_names` is respected, so hosts can control the field wording +without touching the data. + +## Decal patterns (opt-in) + +ECharts can overlay per-series texture patterns so series remain +distinguishable without color (color-vision deficiency support): + +```ts +assembleECharts(input, /* options: */) // via input.options +// { ...input, options: { a11yDecal: true } } +``` + +When `options.a11yDecal` is `true`, the compiled option gains +`aria.decal.show: true`. This is off by default because decals visibly change +the chart; the description metadata above is invisible and always on. + +## Chart.js wiring note + +Chart.js renders to a canvas, which has no native description surface. The +compiled config carries the text as `_a11y.description`; hosts should set it +on the canvas element: + +```ts +const config = assembleChartjs(input); +canvas.setAttribute('role', 'img'); +canvas.setAttribute('aria-label', config._a11y.description); +``` + +## Chartability coverage + +Mapped against [Chartability](https://chartability.github.io/POUR-CAF/) +heuristics, this baseline addresses the critical "no title/summary/caption" +failure (generated descriptions) and part of "conveys meaning through visuals +alone" (aria metadata; decal for color-independent series identity on +ECharts). Not yet covered at the compile step: contrast floors on palette +selection, text-size floors, and data-density checks — candidates for +follow-up work on the existing `ChartWarning` channel. Keyboard navigation, +screen-reader interaction testing, and cognitive-load concerns live with the +host application, outside a compiler's reach. diff --git a/docs/api-reference.md b/docs/api-reference.md index 7eda96b..d901bcb 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -272,3 +272,4 @@ Inspect `_warnings` or `ChartWarning` arrays in integration code to surface trun - [Semantic Type](/documentation/semantic-types) — type hierarchy and resolution - [Getting started](/documentation/getting-started) — hands-on walkthrough - [Extending backends](/documentation/adding-a-backend) — new `assemble*()` target +- [Accessibility](/documentation/accessibility) — generated descriptions, aria, decal opt-in diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index 92aad55..641be36 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -31,6 +31,7 @@ import { LayoutDeclaration, InstantiateContext, } from '../core/types'; +import { buildChartDescription } from '../core/a11y-description'; import type { ChartWarning } from '../core/types'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; @@ -421,6 +422,18 @@ export function assembleChartjs(input: ChartAssemblyInput): any { cjsConfig._dataLength = values.length; + // Accessible description metadata. Chart.js renders to a canvas with no + // native description surface, so hosts read `_a11y.description` and set it + // as the canvas aria-label (see docs/accessibility.md). + cjsConfig._a11y = { + description: buildChartDescription({ + chartType, + channelSemantics, + table: values, + fieldDisplayNames: input.field_display_names, + }), + }; + if (pivoted.surface) { cjsConfig._pivot = pivoted.surface; } diff --git a/packages/flint-js/src/core/a11y-description.ts b/packages/flint-js/src/core/a11y-description.ts new file mode 100644 index 0000000..c03fc97 --- /dev/null +++ b/packages/flint-js/src/core/a11y-description.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Accessible chart description generation. + * + * Builds a short, deterministic, screen-reader-first description of a chart + * from the resolved channel semantics and the data table. The copy is limited + * to structural and statistical content (what the chart is and what the data + * spans) — it never makes perceptual or interpretive claims ("X is trending + * up"), which a compiler cannot verify. + * + * Backends inject the result where their renderer exposes it: + * Vega-Lite `description`, ECharts `aria.label.description`, and a + * `_a11y.description` metadata field for Chart.js hosts. + */ + +import type { ChannelSemantics } from './types'; + +/** Channels that carry the measure in priority order. */ +const MEASURE_CHANNELS = ['y', 'x', 'size', 'color']; +/** Channels that carry the category/axis dimension in priority order. */ +const DIMENSION_CHANNELS = ['x', 'y', 'column', 'row']; +/** Channels that split the data into series. */ +const SERIES_CHANNELS = ['color', 'group', 'detail', 'strokeDash']; + +const isDiscrete = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; + +function displayName( + field: string, + fieldDisplayNames?: Record, +): string { + return fieldDisplayNames?.[field] ?? field; +} + +/** Compact, locale-neutral number formatting for range statements. */ +function formatStat(v: number): string { + if (!Number.isFinite(v)) return String(v); + const abs = Math.abs(v); + if (abs >= 1e9) return `${trimTrailingZero(v / 1e9)}B`; + if (abs >= 1e6) return `${trimTrailingZero(v / 1e6)}M`; + if (abs >= 1e4) return `${trimTrailingZero(v / 1e3)}K`; + if (Number.isInteger(v)) return String(v); + return String(Math.round(v * 100) / 100); +} + +function trimTrailingZero(v: number): string { + const r = Math.round(v * 10) / 10; + return Number.isInteger(r) ? String(r) : String(r); +} + +function measureLabel( + cs: ChannelSemantics, + fieldDisplayNames?: Record, +): string { + const name = displayName(cs.field, fieldDisplayNames); + const unit = cs.semanticAnnotation?.unit; + return unit ? `${name} (${unit})` : name; +} + +/** + * Build a short accessible description for a compiled chart. + * + * Structure: " of by [, grouped by ]. + * [ categories.] [ series.] [Range .]" + */ +export function buildChartDescription(args: { + chartType: string; + channelSemantics: Record; + table: Record[]; + fieldDisplayNames?: Record; +}): string { + const { chartType, channelSemantics, table, fieldDisplayNames } = args; + + // Resolve the primary measure (first quantitative channel by priority). + let measure: ChannelSemantics | undefined; + for (const ch of MEASURE_CHANNELS) { + const cs = channelSemantics[ch]; + if (cs && cs.type === 'quantitative') { measure = cs; break; } + } + + // Resolve the primary dimension (first discrete/temporal positional channel + // that isn't the measure's own channel). + let dimension: ChannelSemantics | undefined; + for (const ch of DIMENSION_CHANNELS) { + const cs = channelSemantics[ch]; + if (cs && cs !== measure && (isDiscrete(cs.type) || cs.type === 'temporal')) { + dimension = cs; + break; + } + } + + // Resolve the series channel (first discrete series channel distinct from + // measure and dimension). + let series: ChannelSemantics | undefined; + for (const ch of SERIES_CHANNELS) { + const cs = channelSemantics[ch]; + if (cs && cs !== measure && cs !== dimension && isDiscrete(cs.type)) { + series = cs; + break; + } + } + + const parts: string[] = []; + + // L1 — structure. + let head = chartType; + if (measure) head += ` of ${measureLabel(measure, fieldDisplayNames)}`; + if (dimension) head += ` by ${displayName(dimension.field, fieldDisplayNames)}`; + if (series) head += `, grouped by ${displayName(series.field, fieldDisplayNames)}`; + parts.push(`${head}.`); + + // L2 — cheap statistics from the table. + if (dimension && isDiscrete(dimension.type)) { + const n = cardinality(table, dimension.field); + if (n > 0) parts.push(`${n} categories.`); + } + if (series) { + const n = cardinality(table, series.field); + if (n > 1) parts.push(`${n} series.`); + } + if (measure) { + const range = valueRange(table, measure.field); + if (range) parts.push(`Range ${formatStat(range[0])}–${formatStat(range[1])}.`); + } + + return parts.join(' '); +} + +function cardinality(table: Record[], field: string): number { + const seen = new Set(); + for (const row of table) { + const v = row[field]; + if (v != null) seen.add(String(v)); + } + return seen.size; +} + +function valueRange( + table: Record[], + field: string, +): [number, number] | null { + let min = Infinity; + let max = -Infinity; + for (const row of table) { + const v = Number(row[field]); + if (Number.isFinite(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + return min <= max ? [min, max] : null; +} diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index c2d57b1..20cd77c 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -1034,6 +1034,14 @@ export interface ChartAssemblyInput { export interface AssembleOptions { /** Whether to add tooltips to the chart (default: false) */ addTooltips?: boolean; + /** + * Opt in to ECharts decal patterns (`aria.decal`): per-series texture + * overlays that keep series distinguishable without color (color-vision + * deficiency support). Off by default because decals visibly change the + * chart. Accessible descriptions and aria labels are always emitted and + * need no flag. ECharts backend only (default: false). + */ + a11yDecal?: boolean; /** * Fraction of each step reserved for inter-category padding (0–1). * VL pads *inside* the step (band = step × (1 − padding)), so this diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index 8ab5bc7..4371d2d 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -58,6 +58,7 @@ import { LayoutDeclaration, InstantiateContext, } from '../core/types'; +import { buildChartDescription } from '../core/a11y-description'; import type { ChartWarning } from '../core/types'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; @@ -499,6 +500,24 @@ export function assembleECharts(input: ChartAssemblyInput): any { // RESULT // ═══════════════════════════════════════════════════════════════════════ + // Accessible metadata via ECharts' built-in aria module. The generated + // label description is invisible metadata and always on; decal patterns + // visibly change the chart, so they stay behind options.a11yDecal. + if (ecOption.aria == null) { + ecOption.aria = { + enabled: true, + label: { + description: buildChartDescription({ + chartType, + channelSemantics, + table: values, + fieldDisplayNames: input.field_display_names, + }), + }, + ...(effectiveOptions.a11yDecal ? { decal: { show: true } } : {}), + }; + } + // Attach metadata if (warnings.length > 0) { ecOption._warnings = warnings; diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 121ad3a..1d9bf6a 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -52,6 +52,7 @@ import { LayoutDeclaration, InstantiateContext, } from '../core/types'; +import { buildChartDescription } from '../core/a11y-description'; import type { ChartWarning, ChartOption, OptionEvalContext } from '../core/types'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; @@ -671,6 +672,15 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (pivoted.surface) { result._pivot = pivoted.surface; } + + // Accessible description (structural + statistical), exposed through the + // Vega-Lite `description` property (rendered as the SVG aria-label). + if (result.description == null) { + result.description = buildChartDescription({ + chartType, channelSemantics, table: data, fieldDisplayNames, + }); + } + return result; } diff --git a/packages/flint-js/tests/a11y-baseline.test.ts b/packages/flint-js/tests/a11y-baseline.test.ts new file mode 100644 index 0000000..4a0f877 --- /dev/null +++ b/packages/flint-js/tests/a11y-baseline.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assembleECharts, assembleChartjs } from '../src'; +import { buildChartDescription } from '../src/core/a11y-description'; + +const DATA = [ + { region: 'East', revenue: 168 }, + { region: 'South', revenue: 167 }, + { region: 'North', revenue: 145 }, +]; + +const BAR_INPUT = { + data: { values: DATA }, + semantic_types: { region: 'Region', revenue: { semanticType: 'Price', unit: 'USD' } }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + baseSize: { width: 400, height: 300 }, + }, +}; + +const GROUPED_LINE_INPUT = { + data: { + values: [ + { month: '2025-01', team: 'A', score: 0.42 }, + { month: '2025-02', team: 'A', score: 0.51 }, + { month: '2025-01', team: 'B', score: 0.77 }, + { month: '2025-02', team: 'B', score: 0.69 }, + ], + }, + semantic_types: { + month: 'YearMonth', + team: 'Name', + score: { semanticType: 'Percentage', intrinsicDomain: [0, 1] as [number, number] }, + }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'month' }, y: { field: 'score' }, color: { field: 'team' } }, + baseSize: { width: 400, height: 300 }, + }, +}; + +describe('a11y baseline: generated descriptions', () => { + it('describes structure and statistics, nothing interpretive', () => { + const spec = assembleVegaLite(BAR_INPUT) as any; + expect(spec.description).toBe( + 'Bar Chart of revenue (USD) by region. 3 categories. Range 145–168.', + ); + }); + + it('mentions the series field and count for grouped charts', () => { + const spec = assembleVegaLite(GROUPED_LINE_INPUT) as any; + expect(spec.description).toContain('grouped by team'); + expect(spec.description).toContain('2 series.'); + }); + + it('respects field_display_names', () => { + const spec = assembleVegaLite({ + ...BAR_INPUT, + field_display_names: { revenue: 'Revenue', region: 'Sales Region' }, + }) as any; + expect(spec.description).toContain('Revenue (USD) by Sales Region'); + }); + + it('is deterministic', () => { + const a = assembleVegaLite(BAR_INPUT) as any; + const b = assembleVegaLite(BAR_INPUT) as any; + expect(a.description).toBe(b.description); + }); +}); + +describe('a11y baseline: backend surfaces', () => { + it('Vega-Lite gets a top-level description', () => { + const spec = assembleVegaLite(BAR_INPUT) as any; + expect(typeof spec.description).toBe('string'); + expect(spec.description.length).toBeGreaterThan(0); + }); + + it('ECharts enables aria with a label description by default, without decal', () => { + const option = assembleECharts(BAR_INPUT) as any; + expect(option.aria?.enabled).toBe(true); + expect(typeof option.aria?.label?.description).toBe('string'); + expect(option.aria?.decal).toBeUndefined(); + }); + + it('ECharts adds decal patterns only when a11yDecal is opted in', () => { + const option = assembleECharts({ ...BAR_INPUT, options: { a11yDecal: true } }) as any; + expect(option.aria?.decal?.show).toBe(true); + }); + + it('Chart.js carries the description as _a11y metadata', () => { + const config = assembleChartjs(BAR_INPUT) as any; + expect(typeof config._a11y?.description).toBe('string'); + expect(config._a11y.description).toContain('Bar Chart of revenue (USD)'); + }); + + it('keeps compiled outputs JSON-pure', () => { + const vl = assembleVegaLite(BAR_INPUT) as any; + const ec = assembleECharts(BAR_INPUT) as any; + expect(JSON.parse(JSON.stringify(vl.description))).toEqual(vl.description); + expect(JSON.parse(JSON.stringify(ec.aria))).toEqual(ec.aria); + }); +}); + +describe('a11y baseline: generator unit behavior', () => { + it('omits statistics it cannot compute', () => { + const desc = buildChartDescription({ + chartType: 'Scatter Plot', + channelSemantics: {}, + table: [], + }); + expect(desc).toBe('Scatter Plot.'); + }); + + it('formats large ranges compactly', () => { + const desc = buildChartDescription({ + chartType: 'Bar Chart', + channelSemantics: { + x: { field: 'k', semanticAnnotation: { semanticType: 'Category' }, type: 'nominal' } as any, + y: { field: 'v', semanticAnnotation: { semanticType: 'Amount' }, type: 'quantitative' } as any, + }, + table: [ + { k: 'a', v: 1_200_000 }, + { k: 'b', v: 3_400_000 }, + ], + }); + expect(desc).toContain('Range 1.2M–3.4M.'); + }); +});