diff --git a/api/routers/insights.py b/api/routers/insights.py
index 6e7ad7a53d..b167834a73 100644
--- a/api/routers/insights.py
+++ b/api/routers/insights.py
@@ -130,6 +130,9 @@ class DashboardResponse(BaseModel):
total_lines_of_code: int
avg_quality_score: float | None
coverage_percent: float
+ # Number of supported libraries — the denominator behind `coverage_percent`
+ # and the per-spec coverage cells. Served so clients never hardcode it.
+ total_libraries: int
library_stats: list[LibraryDashboardStats]
coverage_matrix: list[CoverageRow]
@@ -386,6 +389,7 @@ async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> D
total_lines_of_code=total_loc,
avg_quality_score=round(sum(all_scores) / len(all_scores), 1) if all_scores else None,
coverage_percent=round(coverage, 1),
+ total_libraries=len(SUPPORTED_LIBRARIES),
library_stats=lib_stats,
coverage_matrix=coverage_rows,
top_implementations=top_impls,
diff --git a/app/src/pages/StatsPage.test.tsx b/app/src/pages/StatsPage.test.tsx
index 17a8fdb77f..628d68143c 100644
--- a/app/src/pages/StatsPage.test.tsx
+++ b/app/src/pages/StatsPage.test.tsx
@@ -25,6 +25,25 @@ vi.mock('src/hooks', () => ({
}),
}));
+// Mirrors core.constants.SUPPORTED_LIBRARIES (15 entries).
+const ALL_LIBRARY_IDS = [
+ 'altair',
+ 'bokeh',
+ 'chartjs',
+ 'd3',
+ 'echarts',
+ 'ggplot2',
+ 'highcharts',
+ 'letsplot',
+ 'makie',
+ 'matplotlib',
+ 'muix',
+ 'plotly',
+ 'plotnine',
+ 'pygal',
+ 'seaborn',
+];
+
const mockDashboard = {
total_specs: 142,
total_implementations: 987,
@@ -32,6 +51,9 @@ const mockDashboard = {
total_lines_of_code: 245_600,
avg_quality_score: 82.5,
coverage_percent: 73,
+ // Denominator of the coverage matrix — served by the API, never hardcoded
+ // in the page (it used to be a stale literal 9).
+ total_libraries: 15,
library_stats: [
{
id: 'matplotlib',
@@ -79,6 +101,12 @@ const mockDashboard = {
plotly: { score: null, has_impl: false },
},
},
+ // A fully covered spec — 15/15, the state the matrix renders as solid green.
+ {
+ spec_id: 'line-basic',
+ title: 'Basic Line Plot',
+ libraries: Object.fromEntries(ALL_LIBRARY_IDS.map(id => [id, { score: 90, has_impl: true }])),
+ },
],
top_implementations: [
{
@@ -229,6 +257,73 @@ describe('StatsPage', () => {
expect(screen.getByText('73%')).toBeInTheDocument();
});
+ it('scales the coverage matrix by total_libraries, not a hardcoded count', async () => {
+ mockFetchSuccess();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('specifications')).toBeInTheDocument();
+ });
+
+ // total_specs (142) * total_libraries (15) = 2130 possible implementations,
+ // and one of the two matrix rows is short of full coverage.
+ expect(screen.getByText(/987 of 2,?130 possible/)).toBeInTheDocument();
+ expect(screen.getByText(/1 below 15\/15/)).toBeInTheDocument();
+ expect(screen.getByText('complete (15/15)')).toBeInTheDocument();
+ });
+
+ it('labels each coverage cell with its count out of the full library set', async () => {
+ mockFetchSuccess();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Basic Scatter Plot: 1/15')).toBeInTheDocument();
+ });
+
+ expect(screen.getByLabelText('Basic Line Plot: 15/15')).toBeInTheDocument();
+ });
+
+ it('falls back to library_stats when an older payload omits total_libraries', async () => {
+ // Frontend-before-API deploy: the cached/older dashboard has no
+ // total_libraries, so the denominator has to come from library_stats.
+ const legacyDashboard: Record = {
+ ...mockDashboard,
+ library_stats: ALL_LIBRARY_IDS.map(id => ({
+ id,
+ name: id,
+ impl_count: 10,
+ avg_score: 85,
+ min_score: 60,
+ max_score: 98,
+ score_buckets: { '85-90': 5 },
+ loc_buckets: { '40-60': 5 },
+ avg_loc: 78,
+ })),
+ };
+ delete legacyDashboard.total_libraries;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes('/insights/visitors')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ points: [] }) });
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(legacyDashboard) });
+ })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Basic Scatter Plot: 1/15')).toBeInTheDocument();
+ });
+
+ expect(screen.getByLabelText('Basic Line Plot: 15/15')).toBeInTheDocument();
+ expect(screen.getByText(/987 of 2,?130 possible/)).toBeInTheDocument();
+ expect(screen.getByText(/1 below 15\/15/)).toBeInTheDocument();
+ });
+
it('renders top implementation cards', async () => {
mockFetchSuccess();
diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx
index 3961fca975..0fc1fd298a 100644
--- a/app/src/pages/StatsPage.tsx
+++ b/app/src/pages/StatsPage.tsx
@@ -77,6 +77,7 @@ interface DashboardData {
total_lines_of_code: number;
avg_quality_score: number | null;
coverage_percent: number;
+ total_libraries: number;
library_stats: LibraryStats[];
coverage_matrix: CoverageRow[];
top_implementations: TopImpl[];
@@ -99,6 +100,33 @@ function formatNum(n: number): string {
return n.toLocaleString();
}
+// Coverage matrix geometry: a 16px visual mark centred in a 24px hit area, so
+// every cell is a WCAG 2.2 SC 2.5.8 sized target (24x24, non-overlapping)
+// while the marks themselves stay a dense strip rather than a chunky grid.
+const COVERAGE_MARK_PX = 16;
+const COVERAGE_TARGET_PX = 24;
+
+/**
+ * Per-cell styling of the coverage matrix: complete specs read as a solid
+ * brand-green block, anything short of full coverage carries an ink outline
+ * (dashed when nothing is implemented yet) so the (few) incomplete specs are
+ * the ones that stand out. The stroke is ink rather than amber because amber
+ * clears neither WCAG 1.4.11 on the cream background nor the style guide's
+ * light-bg caveat (`docs/reference/style-guide.md`, "Light-bg WCAG caveat"),
+ * and here the stroke is the only thing marking a state.
+ */
+function coverageCellStyle(count: number, total: number): Record {
+ if (count >= total) return { backgroundColor: colors.success, border: '1px solid transparent' };
+ if (count === 0)
+ return { backgroundColor: 'var(--bg-elevated)', border: '1px dashed var(--ink)' };
+ // brand green (#009E73) scaled by how much of the library set is covered
+ const intensity = total > 0 ? count / total : 0;
+ return {
+ backgroundColor: `rgba(0, 158, 115, ${0.2 + intensity * 0.6})`,
+ border: '1px solid var(--ink)',
+ };
+}
+
// Shorter labels for the fixed-width (80px) library column so every row stays
// on a single line. The API's canonical names ("Apache ECharts", "MUI X
// Charts") wrap to two lines here; we keep the full names elsewhere (Libraries
@@ -160,6 +188,16 @@ export function StatsPage() {
);
+ // Coverage denominator comes from the API (the canonical supported-library
+ // count); library_stats is the fallback for a stale/cached payload so the
+ // matrix never silently reverts to a hardcoded number.
+ const libCount = data.total_libraries || data.library_stats.length || 1;
+ const coverageCells = (data.coverage_matrix ?? []).map(row => ({
+ row,
+ count: Object.values(row.libraries).filter(c => c.has_impl).length,
+ }));
+ const incompleteSpecs = coverageCells.filter(c => c.count < libCount).length;
+
const dailyImpls = data.daily_impls ?? [];
const maxDaily = Math.max(...dailyImpls.map(d => d.count), 1);
const visitorPoints = visitors ?? [];
@@ -510,65 +548,73 @@ export function StatsPage() {
mb: 1,
}}
>
- {data.coverage_percent}% · {data.total_implementations} of {data.total_specs * 9} possible
+ {data.coverage_percent}% · {data.total_implementations} of {data.total_specs * libCount}{' '}
+ possible
+ {incompleteSpecs > 0 && ` · ${incompleteSpecs} below ${libCount}/${libCount}`}
-
- {data.coverage_matrix.map(row => {
- const count = Object.values(row.libraries).filter(c => c.has_impl).length;
- const intensity = count / 9;
- return (
-
-
-
- );
- })}
+ {/* Each link fills a 24px square (SC 2.5.8) and paints the 16px mark
+ with its own background, so the visible strip stays dense while the
+ tap target is the full square. */}
+
+ {coverageCells.map(({ row, count }) => (
+
+ span': {
+ width: COVERAGE_MARK_PX,
+ height: COVERAGE_MARK_PX,
+ borderRadius: '3px',
+ boxSizing: 'border-box',
+ ...coverageCellStyle(count, libCount),
+ },
+ '&:hover > span': {
+ outline: `2px solid ${colors.success}`,
+ outlineOffset: '1px',
+ },
+ }}
+ >
+
+
+
+ ))}
-
-
- less
-
- {[0, 0.25, 0.5, 0.75, 1].map(v => (
-
+ {/* Legend — three states, not a gradient: the interesting signal is
+ "which specs are NOT complete", and those are the minority. */}
+
+ {[
+ { label: `complete (${libCount}/${libCount})`, count: libCount },
+ { label: 'partial', count: Math.max(libCount - 1, 1) },
+ { label: 'none', count: 0 },
+ ].map(({ label, count }) => (
+
+
+
+ {label}
+
+
))}
-
- more
-
{/* Timeline — daily implementation updates over the last 28 days.
diff --git a/changelog.d/stats-coverage-denominator.md b/changelog.d/stats-coverage-denominator.md
new file mode 100644
index 0000000000..63379dd577
--- /dev/null
+++ b/changelog.d/stats-coverage-denominator.md
@@ -0,0 +1,21 @@
+### Fixed
+
+- **Coverage matrix on the stats page counted against 9 libraries instead of
+ 15.** Every cell tooltip read `15/9` for a fully covered spec, and the
+ "possible implementations" total was short by the same factor, because the
+ page carried a hardcoded library count from back when nine were supported.
+ `/insights/dashboard` now serves `total_libraries` (the canonical
+ `SUPPORTED_LIBRARIES` size that already backs `coverage_percent`) and the page
+ renders against it, so the denominator follows the library set instead of
+ drifting from it.
+
+### Changed
+
+- **Coverage cells read as three states, not a gradient.** The mark grew from
+ 10 px to 16 px inside a 24 px hit area (WCAG 2.2 SC 2.5.8), and full coverage
+ now renders as solid brand green while anything short of it carries an ink
+ outline (dashed when a spec has no implementation at all), with a labelled
+ legend replacing the less/more ramp.
+ The interesting signal is which specs are *not* complete, and those are the
+ minority — the old opacity ramp made them the hardest cells to pick out. The
+ summary line also names how many specs are below full coverage.
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index 0d2a328239..a7c5fc1b13 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -2135,6 +2135,9 @@ def test_dashboard_with_db(self, client: TestClient, mock_spec) -> None:
assert data["total_lines_of_code"] == 500
assert data["total_interactive"] == 0
assert len(data["library_stats"]) == len(SUPPORTED_LIBRARIES)
+ # Denominator behind coverage_percent — the stats page renders the
+ # matrix against this instead of hardcoding a library count.
+ assert data["total_libraries"] == len(SUPPORTED_LIBRARIES)
assert isinstance(data["coverage_matrix"], list)
assert isinstance(data["score_distribution"], dict)
assert isinstance(data["tag_distribution"], dict)