From 010194e004a529e764761499cb69ed64105ebf22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 10:46:00 +0000 Subject: [PATCH 1/2] fix(stats): count coverage against all 15 libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stats-page coverage matrix divided by a hardcoded 9 — a leftover from when nine libraries were supported — so a fully covered spec showed "15/9" and the "of N possible" total was computed against the wrong denominator. /insights/dashboard now serves total_libraries (len(SUPPORTED_LIBRARIES), the same value already behind coverage_percent) and the page renders the matrix against it, so the denominator tracks the library set. Also reworks the cells for legibility: 14px instead of 10px, full coverage as solid brand green and anything below it outlined in amber (dashed when a spec has no implementation), a labelled three-state legend instead of the less/more opacity ramp, and a count of specs below full coverage in the summary line — the incomplete specs are the minority and were the hardest cells to spot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AkKTuCSmiEeUaPQDK5kXqS --- api/routers/insights.py | 4 + app/src/pages/StatsPage.test.tsx | 56 +++++++++ app/src/pages/StatsPage.tsx | 138 +++++++++++++--------- changelog.d/stats-coverage-denominator.md | 20 ++++ tests/unit/api/test_routers.py | 3 + 5 files changed, 165 insertions(+), 56 deletions(-) create mode 100644 changelog.d/stats-coverage-denominator.md diff --git a/api/routers/insights.py b/api/routers/insights.py index 6e7ad7a53d9..b167834a73f 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 17a8fdb77f9..973a28c7272 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,34 @@ 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('renders top implementation cards', async () => { mockFetchSuccess(); diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx index 3961fca975a..7a5d0981ddc 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,27 @@ function formatNum(n: number): string { return n.toLocaleString(); } +// Coverage matrix cell size. Bigger than the original 10px so the three states +// below stay distinguishable (and tappable) on a phone. +const COVERAGE_CELL_PX = 14; + +/** + * Per-cell styling of the coverage matrix: complete specs read as a solid + * brand-green block, anything short of full coverage carries an amber outline + * so the (few) incomplete specs are the ones that stand out. + */ +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 ${colors.warning}` }; + // 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 ${colors.warning}`, + }; +} + // 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 +182,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 +542,59 @@ 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 ( - - - - ); - })} + + {coverageCells.map(({ row, count }) => ( + + + + ))} - - - 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 00000000000..4e29bc99a92 --- /dev/null +++ b/changelog.d/stats-coverage-denominator.md @@ -0,0 +1,20 @@ +### 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.** Cells grew from 10 px + to 14 px and full coverage now renders as solid brand green while anything + short of it carries an amber 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 0d2a3282391..a7c5fc1b137 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) From 53eac86bb7a8ffe24723c60b3a6688e28c9e0de3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 10:53:21 +0000 Subject: [PATCH 2/2] fix(stats): ink stroke and 24px targets for coverage cells Addresses the Copilot review on the coverage matrix: - The amber outline was the only marker of the partial/none states, and amber is one of the palette colors the style guide flags as failing on the cream background (1.46:1, below WCAG 1.4.11's 3:1). Switched both incomplete branches to an ink stroke, which is theme-adaptive and keeps the solid/dashed distinction. - Each cell is now a 16px mark centred in a 24px link, so the tap target meets WCAG 2.2 SC 2.5.8 without turning the dense strip into a grid of chunky blocks. - Added a test for the library_stats fallback, so a frontend-before-API deploy (payload without total_libraries) keeps the right denominator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AkKTuCSmiEeUaPQDK5kXqS --- app/src/pages/StatsPage.test.tsx | 39 ++++++++++++++++ app/src/pages/StatsPage.tsx | 56 +++++++++++++++-------- changelog.d/stats-coverage-denominator.md | 9 ++-- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/app/src/pages/StatsPage.test.tsx b/app/src/pages/StatsPage.test.tsx index 973a28c7272..628d68143c7 100644 --- a/app/src/pages/StatsPage.test.tsx +++ b/app/src/pages/StatsPage.test.tsx @@ -285,6 +285,45 @@ describe('StatsPage', () => { 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 7a5d0981ddc..0fc1fd298a0 100644 --- a/app/src/pages/StatsPage.tsx +++ b/app/src/pages/StatsPage.tsx @@ -100,24 +100,30 @@ function formatNum(n: number): string { return n.toLocaleString(); } -// Coverage matrix cell size. Bigger than the original 10px so the three states -// below stay distinguishable (and tappable) on a phone. -const COVERAGE_CELL_PX = 14; +// 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 amber outline - * so the (few) incomplete specs are the ones that stand out. + * 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 ${colors.warning}` }; + 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 ${colors.warning}`, + border: '1px solid var(--ink)', }; } @@ -546,23 +552,37 @@ export function StatsPage() { possible {incompleteSpecs > 0 && ` · ${incompleteSpecs} below ${libCount}/${libCount}`} - + {/* 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', + }, }} - /> + > + + ))} @@ -577,8 +597,8 @@ export function StatsPage() {