diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx
index 20d3992ec8a..b5d0dc1aa1a 100644
--- a/app/src/pages/DebugPage.test.tsx
+++ b/app/src/pages/DebugPage.test.tsx
@@ -568,3 +568,313 @@ describe('DebugPage agent usage', () => {
]);
});
});
+
+describe('DebugPage spec matrix and feedback triage', () => {
+ const TRIAGE_SPECS = [
+ {
+ id: 'bar-grouped',
+ title: 'Zebra Bar Chart',
+ updated: '2026-02-20T12:00:00',
+ avg_score: 84.5,
+ altair: 95,
+ bokeh: 95,
+ chartjs: 95,
+ d3: 95,
+ echarts: 95,
+ ggplot2: 95,
+ highcharts: 95,
+ letsplot: 95,
+ makie: 95,
+ matplotlib: 95,
+ muix: 95,
+ plotly: 95,
+ plotnine: 95,
+ pygal: 95,
+ seaborn: 78,
+ },
+ {
+ id: 'heatmap-annotated',
+ title: 'Alpha Heatmap',
+ updated: '2026-03-10T12:00:00',
+ avg_score: 91.0,
+ altair: null,
+ bokeh: null,
+ chartjs: null,
+ d3: null,
+ echarts: null,
+ ggplot2: null,
+ highcharts: null,
+ letsplot: null,
+ makie: null,
+ // One library below 90 while the average stays at 91.0: the low-score
+ // filter must look at each library, not at the average.
+ matplotlib: 85,
+ muix: null,
+ plotly: null,
+ plotnine: null,
+ pygal: null,
+ seaborn: null,
+ },
+ {
+ id: 'scatter-basic',
+ title: 'Middle Scatter',
+ updated: '2026-01-15T12:00:00',
+ avg_score: 96.0,
+ altair: 96,
+ bokeh: 96,
+ chartjs: 96,
+ d3: 96,
+ echarts: 96,
+ ggplot2: 96,
+ highcharts: 96,
+ letsplot: 96,
+ makie: 96,
+ matplotlib: 96,
+ muix: 96,
+ plotly: 96,
+ plotnine: 96,
+ pygal: 96,
+ seaborn: 96,
+ },
+ ];
+
+ const TRIAGE_MESSAGES = [
+ {
+ id: 'fb-1',
+ message: 'Axis labels overlap on mobile',
+ reaction: 'bug',
+ contact: 'alice@example.com',
+ path: '/scatter-basic',
+ spec_id: 'scatter-basic',
+ viewport: '375x812',
+ status: 'new' as const,
+ created_at: '2026-05-21T09:00:00',
+ },
+ ];
+
+ beforeEach(({ onTestFinished }) => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ onTestFinished(() => {
+ sessionStorage.clear();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+ });
+
+ function renderedSpecOrder(): string[] {
+ const knownIds = new Set(TRIAGE_SPECS.map(s => s.id));
+ return screen
+ .getAllByRole('link')
+ .map(link => link.textContent ?? '')
+ .filter(text => knownIds.has(text));
+ }
+
+ function expectSortState(
+ activeCol: 'spec id' | 'title' | 'avg' | 'updated',
+ dir: 'asc' | 'desc',
+ expectedOrder: string[]
+ ) {
+ expect(renderedSpecOrder()).toEqual(expectedOrder);
+ const arrow = dir === 'asc' ? '▲' : '▼';
+ for (const label of ['spec id', 'title', 'avg', 'updated'] as const) {
+ const btn = screen.getByRole('button', { name: new RegExp(`^${label}`) });
+ if (label === activeCol) {
+ expect(btn).toHaveTextContent(arrow);
+ } else {
+ expect(btn).not.toHaveTextContent(/[▲▼]/);
+ }
+ }
+ }
+
+ function stubPageFetch(options?: { patchStatus?: () => number }) {
+ const fetchMock = vi.fn((url: string, init?: RequestInit) => {
+ if (url.includes('/debug/ping')) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ database_connected: true,
+ response_time_ms: 10,
+ timestamp: '2026-05-21T12:00:00',
+ }),
+ });
+ }
+ if (url.includes('/debug/agent-usage')) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ status: 'not_configured',
+ days: 7,
+ sessions: 0,
+ plots_shipped: 0,
+ kept_rate: null,
+ kept_sessions: 0,
+ shipped_sessions: 0,
+ repair_rate: null,
+ repaired_runs: 0,
+ runs: 0,
+ top_specs: [],
+ top_formats: [],
+ top_refusals: [],
+ generated_at: '2026-05-21T12:00:00',
+ }),
+ });
+ }
+ if (url.includes('/debug/agent/cases')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ items: [], has_more: false }),
+ });
+ }
+ if (url.includes('/debug/feedback/messages')) {
+ const parsed = new URL(url, 'https://api.anyplot.ai');
+ const status = parsed.searchParams.get('status');
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(status === 'open' ? TRIAGE_MESSAGES : []),
+ });
+ }
+ if (url.includes('/debug/feedback/top')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
+ }
+ if (init?.method === 'PATCH') {
+ const status = options?.patchStatus?.() ?? 200;
+ return Promise.resolve({
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve({ status: 'ok' }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ ...mockDebugData,
+ total_specs: TRIAGE_SPECS.length,
+ specs: TRIAGE_SPECS,
+ }),
+ });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+ }
+
+ it('sorts specs by each sortable header with per-column default direction and toggles on second click', async () => {
+ stubPageFetch();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ });
+
+ // Initial sort is updated descending
+ expectSortState('updated', 'desc', ['heatmap-annotated', 'bar-grouped', 'scatter-basic']);
+
+ // Second click on updated toggles to ascending
+ fireEvent.click(screen.getByRole('button', { name: /^updated/ }));
+ expectSortState('updated', 'asc', ['scatter-basic', 'bar-grouped', 'heatmap-annotated']);
+
+ // Clicking spec id defaults to ascending, second click toggles to descending
+ fireEvent.click(screen.getByRole('button', { name: /^spec id/ }));
+ expectSortState('spec id', 'asc', ['bar-grouped', 'heatmap-annotated', 'scatter-basic']);
+
+ fireEvent.click(screen.getByRole('button', { name: /^spec id/ }));
+ expectSortState('spec id', 'desc', ['scatter-basic', 'heatmap-annotated', 'bar-grouped']);
+
+ // Clicking title defaults to ascending, second click toggles to descending
+ fireEvent.click(screen.getByRole('button', { name: /^title/ }));
+ expectSortState('title', 'asc', ['heatmap-annotated', 'scatter-basic', 'bar-grouped']);
+
+ fireEvent.click(screen.getByRole('button', { name: /^title/ }));
+ expectSortState('title', 'desc', ['bar-grouped', 'scatter-basic', 'heatmap-annotated']);
+
+ // Clicking avg defaults to descending, second click toggles to ascending
+ fireEvent.click(screen.getByRole('button', { name: /^avg/ }));
+ expectSortState('avg', 'desc', ['scatter-basic', 'heatmap-annotated', 'bar-grouped']);
+
+ fireEvent.click(screen.getByRole('button', { name: /^avg/ }));
+ expectSortState('avg', 'asc', ['bar-grouped', 'heatmap-annotated', 'scatter-basic']);
+
+ // Clicking updated from another column defaults to descending
+ fireEvent.click(screen.getByRole('button', { name: /^updated/ }));
+ expectSortState('updated', 'desc', ['heatmap-annotated', 'bar-grouped', 'scatter-basic']);
+ });
+
+ it('narrows rows with incomplete and low-score filters and restores them on second click', async () => {
+ stubPageFetch();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ });
+
+ const incompleteBtn = screen.getByRole('button', { name: /incomplete <15/i });
+ fireEvent.click(incompleteBtn);
+ expect(screen.getByText('1 / 3')).toBeInTheDocument();
+ expect(renderedSpecOrder()).toEqual(['heatmap-annotated']);
+
+ fireEvent.click(incompleteBtn);
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped', 'scatter-basic']);
+
+ const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i });
+ fireEvent.click(lowScoresBtn);
+ expect(screen.getByText('2 / 3')).toBeInTheDocument();
+ expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped']);
+
+ fireEvent.click(lowScoresBtn);
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped', 'scatter-basic']);
+ });
+
+ it('patches feedback status with X-Admin-Token, reverts on 500, and refetches when status filter changes', async () => {
+ sessionStorage.setItem('anyplot.adminToken', 'admin-secret');
+ let nextPatchStatus = 200;
+ const fetchMock = stubPageFetch({ patchStatus: () => nextPatchStatus });
+
+ render();
+
+ const msgText = await screen.findByText('Axis labels overlap on mobile');
+ const msgRow = msgText.parentElement?.parentElement;
+ expect(msgRow).not.toBeNull();
+ const statusSelect = within(msgRow as HTMLElement).getByRole('combobox');
+ expect(statusSelect).toHaveValue('new');
+
+ fireEvent.change(statusSelect, { target: { value: 'done' } });
+ expect(statusSelect).toHaveValue('done');
+
+ await waitFor(() => {
+ const patchCalls = fetchMock.mock.calls.filter(call => call[1]?.method === 'PATCH');
+ expect(patchCalls).toHaveLength(1);
+ });
+ const [patchUrl, patchInit] = fetchMock.mock.calls.find(call => call[1]?.method === 'PATCH')!;
+ expect(patchUrl).toMatch(/\/debug\/feedback\/fb-1$/);
+ expect(patchInit?.body).toBe(JSON.stringify({ status: 'done' }));
+ expect((patchInit?.headers as Record)?.['X-Admin-Token']).toBe('admin-secret');
+
+ // When PATCH answers 500, the select reverts to the previous status
+ nextPatchStatus = 500;
+ fireEvent.change(statusSelect, { target: { value: 'wont_solve' } });
+ await waitFor(() => {
+ expect(statusSelect).toHaveValue('done');
+ });
+
+ // Choosing a status filter refetches .../debug/feedback/messages?status=&limit=50
+ const messagesHeaderBar = screen.getByRole('heading', { name: 'feedback · messages' })
+ .parentElement?.parentElement;
+ expect(messagesHeaderBar).not.toBeNull();
+ const filterSelect = within(messagesHeaderBar as HTMLElement).getByRole('combobox');
+ fireEvent.change(filterSelect, { target: { value: 'done' } });
+
+ await waitFor(() => {
+ const messageUrls = fetchMock.mock.calls
+ .map(call => call[0])
+ .filter(url => url.includes('/debug/feedback/messages'));
+ expect(messageUrls).toContainEqual(
+ expect.stringMatching(/\/debug\/feedback\/messages\?status=done&limit=50$/)
+ );
+ });
+ });
+});
diff --git a/app/src/pages/PalettePage.test.tsx b/app/src/pages/PalettePage.test.tsx
new file mode 100644
index 00000000000..b2aa37f3fb8
--- /dev/null
+++ b/app/src/pages/PalettePage.test.tsx
@@ -0,0 +1,159 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PalettePage } from 'src/pages/PalettePage';
+import { paths } from 'src/routes/paths';
+import { act, fireEvent, render, screen, within } from 'src/test-utils';
+
+const mockTrackPageview = vi.fn();
+
+vi.mock('src/hooks', () => ({
+ useAnalytics: () => ({
+ trackPageview: mockTrackPageview,
+ }),
+ useTheme: () => ({
+ isDark: false,
+ }),
+}));
+
+const IMPRINT_LABELS = [
+ 'Copy #009E73 (brand green) to clipboard',
+ 'Copy #C475FD (lavender) to clipboard',
+ 'Copy #4467A3 (blue) to clipboard',
+ 'Copy #BD8233 (ochre) to clipboard',
+ 'Copy #AE3030 (matte red) to clipboard',
+ 'Copy #2ABCCD (cyan) to clipboard',
+ 'Copy #954477 (rose) to clipboard',
+ 'Copy #99B314 (lime) to clipboard',
+];
+
+const CVD_OPTIMAL_LABELS = [
+ 'Copy #009E73 (brand green) to clipboard',
+ 'Copy #C475FD (lavender) to clipboard',
+ 'Copy #99B314 (lime) to clipboard',
+ 'Copy #954477 (rose) to clipboard',
+ 'Copy #AE3030 (matte red) to clipboard',
+ 'Copy #2ABCCD (cyan) to clipboard',
+ 'Copy #4467A3 (blue) to clipboard',
+ 'Copy #BD8233 (ochre) to clipboard',
+];
+
+describe('PalettePage', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ mockTrackPageview.mockReset();
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('emits a pageview analytics event for paths.palette on mount', () => {
+ render();
+
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1);
+ expect(mockTrackPageview).toHaveBeenCalledWith(paths.palette);
+ });
+
+ it('shows copied ✓ for 1,499 ms and restores the hex at 1,500 ms for both a palette swatch and a semantic anchor', async () => {
+ vi.useFakeTimers();
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal('navigator', { clipboard: { writeText } });
+
+ render();
+
+ const brandButton = screen.getByRole('button', {
+ name: 'Copy #009E73 (brand green) to clipboard',
+ });
+ expect(within(brandButton).getByText('#009E73')).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.click(brandButton);
+ });
+
+ expect(writeText).toHaveBeenCalledWith('#009E73');
+ expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1499);
+ });
+ expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(within(brandButton).queryByText('copied ✓')).not.toBeInTheDocument();
+ expect(within(brandButton).getByText('#009E73')).toBeInTheDocument();
+
+ const amberButton = screen.getByRole('button', {
+ name: 'Copy #DDCC77 (amber) to clipboard',
+ });
+ expect(amberButton).toHaveTextContent('#DDCC77');
+
+ await act(async () => {
+ fireEvent.click(amberButton);
+ });
+
+ expect(writeText).toHaveBeenCalledWith('#DDCC77');
+ expect(amberButton).toHaveTextContent('copied ✓');
+
+ act(() => {
+ vi.advanceTimersByTime(1499);
+ });
+ expect(amberButton).toHaveTextContent('copied ✓');
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(amberButton).toHaveTextContent('#DDCC77');
+ });
+
+ it('reorders the palette grid to CVD-optimal order (lime third) and restores imprint default order', () => {
+ render();
+
+ const gridSection = screen
+ .getByRole('heading', { name: /the 8 categorical hues/i })
+ .closest('section')!;
+ const getGridLabels = () =>
+ within(gridSection)
+ .getAllByRole('button', { name: /^Copy #[0-9A-F]{6}/ })
+ .map(btn => btn.getAttribute('aria-label'));
+
+ expect(getGridLabels()).toEqual(IMPRINT_LABELS);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'CVD-optimal (max ΔE under colorblindness)' })
+ );
+ expect(getGridLabels()).toEqual(CVD_OPTIMAL_LABELS);
+
+ fireEvent.click(screen.getByRole('button', { name: 'imprint (default)' }));
+ expect(getGridLabels()).toEqual(IMPRINT_LABELS);
+ });
+
+ it('wires wheel expansion, comparison palette selection, language tabs, and the OKLCH switch through page state', () => {
+ render();
+
+ const expandWheel = screen.getByRole('button', {
+ name: 'Expand the hue–chroma wheel to compare palettes',
+ });
+ expect(expandWheel).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(expandWheel);
+ expect(screen.getByRole('button', { name: 'Collapse the hue–chroma wheel' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ );
+
+ const okabeButton = screen.getByRole('button', { name: 'Okabe-Ito' });
+ expect(okabeButton).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(okabeButton);
+ expect(okabeButton).toHaveAttribute('aria-pressed', 'true');
+
+ fireEvent.click(screen.getByRole('tab', { name: 'R' }));
+ expect(screen.getByText(/IMPRINT <- list\(/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('switch', { name: 'OKLCH' }));
+ expect(screen.getByText(/# oklch\(0\.620 0\.130 165\.5\)/)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/debug/DebugOverviewSections.test.tsx b/app/src/sections/debug/DebugOverviewSections.test.tsx
new file mode 100644
index 00000000000..7169b5f76e8
--- /dev/null
+++ b/app/src/sections/debug/DebugOverviewSections.test.tsx
@@ -0,0 +1,286 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { CommonWeaknesses } from 'src/sections/debug/CommonWeaknesses';
+import { DailyActivityChart } from 'src/sections/debug/DailyActivityChart';
+import { KPIGrid } from 'src/sections/debug/KPIGrid';
+import { LibraryCoverage } from 'src/sections/debug/LibraryCoverage';
+import { ProblemAreas } from 'src/sections/debug/ProblemAreas';
+import { RecentActivity } from 'src/sections/debug/RecentActivity';
+import { SystemHealthRow } from 'src/sections/debug/SystemHealthRow';
+import { render, screen, userEvent, within } from 'src/test-utils';
+
+describe('SystemHealthRow', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('shows initial system health before live pings arrive and updates with ping history', () => {
+ const systemData = {
+ database_connected: true,
+ api_response_time_ms: 42,
+ timestamp: '2026-05-20T12:00:00Z',
+ };
+
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByText('database connected')).toBeInTheDocument();
+ expect(screen.getByText('42ms')).toBeInTheDocument();
+ expect(screen.queryByText(/avg /i)).not.toBeInTheDocument();
+ expect(screen.getByText(/updated /i)).toBeInTheDocument();
+
+ // Rerender with mixed pings where the latest ping failed
+ rerender(
+
+ );
+
+ expect(screen.getByText('database down')).toBeInTheDocument();
+ expect(screen.getByText('250ms')).toBeInTheDocument();
+ expect(screen.getByText('avg 100')).toBeInTheDocument();
+
+ // Rerender where latest ping is ok
+ rerender(
+
+ );
+ expect(screen.getByText('database connected')).toBeInTheDocument();
+ expect(screen.getByText('80ms')).toBeInTheDocument();
+ expect(screen.getByText('avg 70')).toBeInTheDocument();
+ });
+});
+
+describe('KPIGrid', () => {
+ it('renders formatted KPI values with suffixes and em-dash for null', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('120')).toBeInTheDocument();
+ expect(screen.getByText('specs')).toBeInTheDocument();
+ expect(screen.getByText('1.8K')).toBeInTheDocument();
+ expect(screen.getByText('impls')).toBeInTheDocument();
+ expect(screen.getByText('92.4%')).toBeInTheDocument();
+ expect(screen.getByText('coverage')).toBeInTheDocument();
+ expect(screen.getByText('—')).toBeInTheDocument();
+ expect(screen.getByText('avg quality')).toBeInTheDocument();
+ });
+});
+
+describe('DailyActivityChart', () => {
+ it('renders activity header, date range endpoints, bar tooltip on hover, and handles empty data', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByText('daily activity')).toBeInTheDocument();
+ expect(screen.getByText('implementation updates · last 30 days')).toBeInTheDocument();
+ expect(screen.getByText('2026-05-01')).toBeInTheDocument();
+ expect(screen.getByText('2026-05-30')).toBeInTheDocument();
+
+ const bar = screen.getByLabelText('2026-05-02: 12 impls');
+ await user.hover(bar);
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('2026-05-02: 12 impls');
+
+ rerender();
+ expect(screen.queryByLabelText(/impls$/)).not.toBeInTheDocument();
+ expect(screen.queryByText('2026-05-01')).not.toBeInTheDocument();
+ expect(screen.queryByText('2026-05-30')).not.toBeInTheDocument();
+ });
+});
+
+describe('LibraryCoverage', () => {
+ it('sorts libraries by implementation count descending and formats scores and null fallbacks', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('libraries')).toBeInTheDocument();
+ expect(screen.getByText('impl count · avg · min–max')).toBeInTheDocument();
+
+ const names = screen.getAllByText(/Matplotlib|Seaborn|Pygal/).map(el => el.textContent);
+ expect(names).toEqual(['Matplotlib', 'Seaborn', 'Pygal']);
+
+ expect(screen.getByText('90')).toBeInTheDocument();
+ expect(screen.getByText('93.4')).toBeInTheDocument();
+ expect(screen.getByText('80–99')).toBeInTheDocument();
+ expect(screen.getByText('72–96')).toBeInTheDocument();
+ expect(screen.getByText('—–—')).toBeInTheDocument();
+ });
+});
+
+describe('RecentActivity', () => {
+ it('renders nothing when empty and lists recent implementation links when populated', () => {
+ const { container, rerender } = render();
+ expect(container).toBeEmptyDOMElement();
+
+ rerender(
+
+ );
+
+ expect(screen.getByText('recent activity')).toBeInTheDocument();
+ const links = screen.getAllByRole('link');
+ expect(links[0]).toHaveAttribute('href', '/scatter-basic/python/matplotlib');
+ expect(links[0]).toHaveTextContent('Basic Scatter');
+ expect(links[0]).toHaveTextContent('95');
+ expect(links[0]).toHaveTextContent('impl-generate');
+
+ expect(links[1]).toHaveAttribute('href', '/bar-grouped/javascript/d3');
+ expect(links[1]).toHaveTextContent('Grouped Bar');
+ });
+});
+
+describe('CommonWeaknesses', () => {
+ it('renders nothing when empty and displays weakness tags across frequency ratios', () => {
+ const { container, rerender } = render();
+ expect(container).toBeEmptyDOMElement();
+
+ rerender(
+
+ );
+
+ expect(screen.getByText('common weaknesses')).toBeInTheDocument();
+ expect(screen.getByText('overlapping labels')).toBeInTheDocument();
+ expect(screen.getByText('20')).toBeInTheDocument();
+ expect(screen.getByText('missing units')).toBeInTheDocument();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getByText('low contrast grid')).toBeInTheDocument();
+ expect(screen.getByText('6')).toBeInTheDocument();
+ expect(screen.getByText('clipped title')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+});
+
+describe('ProblemAreas', () => {
+ it('renders collapsible problem sections with counts and links to affected specs', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('problem areas')).toBeInTheDocument();
+ const lowScoresSection = screen.getByText('low scores (avg < 90)').closest('summary');
+ expect(lowScoresSection).not.toBeNull();
+ expect(within(lowScoresSection as HTMLElement).getByText('2')).toBeInTheDocument();
+
+ const missingPreviewsSection = screen.getByText('missing previews').closest('summary');
+ expect(missingPreviewsSection).not.toBeNull();
+ expect(within(missingPreviewsSection as HTMLElement).getByText('0')).toBeInTheDocument();
+
+ const missingTagsSection = screen.getByText('missing tags').closest('summary');
+ expect(missingTagsSection).not.toBeNull();
+ expect(within(missingTagsSection as HTMLElement).getByText('1')).toBeInTheDocument();
+
+ const oldestSpecsSection = screen.getByText('oldest specs').closest('summary');
+ expect(oldestSpecsSection).not.toBeNull();
+ expect(within(oldestSpecsSection as HTMLElement).getByText('0')).toBeInTheDocument();
+
+ const scatterLink = screen.getByRole('link', { name: /scatter-basic/ });
+ expect(scatterLink).toHaveAttribute('href', '/scatter-basic');
+ expect(scatterLink).toHaveTextContent('seaborn below 90');
+ expect(scatterLink).toHaveTextContent('78.0');
+
+ const heatmapLink = screen.getByRole('link', { name: /heatmap-annotated/ });
+ expect(heatmapLink).toHaveAttribute('href', '/heatmap-annotated');
+ expect(heatmapLink).toHaveTextContent('no domain tag');
+ });
+});
diff --git a/app/src/sections/debug/FeedbackSection.test.tsx b/app/src/sections/debug/FeedbackSection.test.tsx
new file mode 100644
index 00000000000..7397f214c76
--- /dev/null
+++ b/app/src/sections/debug/FeedbackSection.test.tsx
@@ -0,0 +1,242 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { FEEDBACK_STATUS_OPTIONS } from 'src/sections/debug/constants';
+import { CopyClaudePromptButton } from 'src/sections/debug/CopyClaudePromptButton';
+import { type FeedbackMessage, FeedbackSection } from 'src/sections/debug/FeedbackSection';
+import { FeedbackTopList, type FeedbackTopPage } from 'src/sections/debug/FeedbackTopList';
+import { act, fireEvent, render, screen, userEvent, within } from 'src/test-utils';
+import { colors } from 'src/theme';
+
+const TOP_UP: FeedbackTopPage[] = [
+ { path: '/scatter-basic', count: 14, last_seen: '2026-05-20T10:00:00Z' },
+ { path: '/bar-grouped', count: 5, last_seen: null },
+];
+
+const TOP_DOWN: FeedbackTopPage[] = [
+ { path: '/heatmap-annotated', count: 3, last_seen: '2026-05-19T08:00:00Z' },
+];
+
+const INITIAL_MESSAGES: FeedbackMessage[] = [
+ {
+ id: 'fb-1',
+ reaction: 'bug',
+ message: 'Axis labels overlap on mobile',
+ contact: 'alice@example.com',
+ path: '/scatter-basic',
+ status: 'new',
+ created_at: '2026-05-21T09:00:00Z',
+ },
+ {
+ id: 'fb-2',
+ reaction: 'idea',
+ message: 'Add dark mode export',
+ contact: null,
+ path: null,
+ status: 'in_progress',
+ created_at: '2026-05-20T09:00:00Z',
+ },
+ {
+ id: 'fb-3',
+ reaction: 'thumbs_up',
+ message: null,
+ contact: null,
+ path: '/bar-grouped',
+ status: 'done',
+ created_at: '2026-05-19T09:00:00Z',
+ },
+ {
+ id: 'fb-4',
+ reaction: 'thumbs_down',
+ message: 'Colors are hard to distinguish',
+ contact: null,
+ path: '/heatmap-annotated',
+ status: 'wont_solve',
+ created_at: '2026-05-18T09:00:00Z',
+ },
+ {
+ id: 'fb-5',
+ reaction: null,
+ message: 'General note without reaction',
+ contact: null,
+ path: null,
+ status: 'new',
+ created_at: '2026-05-17T09:00:00Z',
+ },
+];
+
+describe('Feedback constants', () => {
+ it('lists the four feedback triage statuses in order', () => {
+ expect(FEEDBACK_STATUS_OPTIONS).toEqual(['new', 'in_progress', 'done', 'wont_solve']);
+ });
+});
+
+describe('FeedbackTopList', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('shows empty state when no entries exist and renders links and counts when populated', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByText('👍 thumbs up')).toBeInTheDocument();
+ expect(screen.getByText('no entries yet')).toBeInTheDocument();
+
+ rerender();
+ expect(screen.queryByText('no entries yet')).not.toBeInTheDocument();
+ expect(screen.getByText('14')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: '/scatter-basic' })).toHaveAttribute(
+ 'href',
+ '/scatter-basic'
+ );
+ expect(screen.getByRole('link', { name: '/bar-grouped' })).toHaveAttribute(
+ 'href',
+ '/bar-grouped'
+ );
+ });
+});
+
+describe('CopyClaudePromptButton', () => {
+ const originalLocation = window.location;
+ const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: originalLocation,
+ });
+ if (originalClipboardDescriptor) {
+ Object.defineProperty(navigator, 'clipboard', originalClipboardDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, 'clipboard');
+ }
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('copies a formatted Claude Code prompt to the clipboard and resets feedback after timeout', async () => {
+ vi.useFakeTimers();
+ userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ ...originalLocation,
+ origin: 'https://anyplot.ai',
+ href: 'https://anyplot.ai/debug',
+ },
+ });
+
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: 'Copy Claude Code prompt' });
+ fireEvent.mouseOver(button);
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(screen.getByRole('tooltip')).toHaveTextContent('copy prompt for Claude Code');
+
+ await act(async () => {
+ fireEvent.click(button);
+ });
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText.mock.calls[0][0]).toContain('https://anyplot.ai/scatter-basic');
+ expect(writeText.mock.calls[0][0]).toContain('bug report');
+ expect(writeText.mock.calls[0][0]).toContain('> Legend overlaps data points');
+
+ // Tooltip text updates to the copied confirmation until the 2000ms timer expires
+ expect(screen.getByRole('tooltip')).toHaveTextContent('copied — paste into Claude Code');
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(screen.getByRole('tooltip')).toHaveTextContent('copy prompt for Claude Code');
+ });
+});
+
+describe('FeedbackSection', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders messages with reactions and contact info and forwards status and filter changes', async () => {
+ const user = userEvent.setup();
+ const setFeedbackStatusFilter = vi.fn();
+ const handleStatusChange = vi.fn();
+
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByText('Axis labels overlap on mobile')).toBeInTheDocument();
+ expect(screen.getByText('contact: alice@example.com')).toBeInTheDocument();
+ expect(screen.getByText('Add dark mode export')).toBeInTheDocument();
+ expect(screen.getByText('Colors are hard to distinguish')).toBeInTheDocument();
+ expect(screen.getByText('General note without reaction')).toBeInTheDocument();
+
+ // Reaction glyphs rendered
+ expect(screen.getByText('🪲')).toBeInTheDocument();
+ expect(screen.getByText('💡')).toBeInTheDocument();
+ expect(screen.getByText('👍')).toBeInTheDocument();
+ expect(screen.getByText('👎')).toBeInTheDocument();
+ expect(screen.getByText('—')).toBeInTheDocument();
+
+ // Find fb-1's status select within its message row instead of by index
+ const fb1Message = screen.getByText('Axis labels overlap on mobile');
+ const fb1Row = fb1Message.parentElement?.parentElement;
+ expect(fb1Row).not.toBeNull();
+ const fb1StatusSelect = within(fb1Row as HTMLElement).getByRole('combobox');
+ expect(fb1StatusSelect).toHaveValue('new');
+
+ await user.selectOptions(fb1StatusSelect, 'done');
+ expect(handleStatusChange).toHaveBeenCalledWith('fb-1', 'done');
+
+ // Status filter select calls its setter
+ const filterSelect = screen.getByDisplayValue('open (new + in progress)');
+ await user.selectOptions(filterSelect, 'done');
+ expect(setFeedbackStatusFilter).toHaveBeenCalledWith('done');
+
+ // Empty state with specific status filter
+ rerender(
+
+ );
+ expect(screen.getByText('no messages with status "done"')).toBeInTheDocument();
+
+ // Empty state with all statuses ('')
+ rerender(
+
+ );
+ expect(screen.getByText('no messages')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/debug/SpecMatrix.test.tsx b/app/src/sections/debug/SpecMatrix.test.tsx
new file mode 100644
index 00000000000..39c481f9532
--- /dev/null
+++ b/app/src/sections/debug/SpecMatrix.test.tsx
@@ -0,0 +1,268 @@
+import type { ComponentProps } from 'react';
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { LIBRARIES } from 'src/constants';
+import { HeaderCell } from 'src/sections/debug/HeaderCell';
+import { SortableHeader } from 'src/sections/debug/SortableHeader';
+import { SpecMatrix, type SpecStatus } from 'src/sections/debug/SpecMatrix';
+import { render, screen, userEvent } from 'src/test-utils';
+
+function makeSpec(overrides: Partial & { id: string; title: string }): SpecStatus {
+ return {
+ updated: '2026-01-15T12:00:00',
+ avg_score: null,
+ altair: null,
+ bokeh: null,
+ chartjs: null,
+ d3: null,
+ echarts: null,
+ ggplot2: null,
+ highcharts: null,
+ letsplot: null,
+ makie: null,
+ matplotlib: null,
+ muix: null,
+ plotly: null,
+ plotnine: null,
+ pygal: null,
+ seaborn: null,
+ ...overrides,
+ };
+}
+
+const ALL_LIBRARIES_FULL = Object.fromEntries(
+ LIBRARIES.map(lib => [lib, 95])
+) as Partial;
+
+const SAMPLE_SPECS: SpecStatus[] = [
+ makeSpec({
+ id: 'scatter-basic',
+ title: 'Basic Scatter Plot',
+ updated: '2026-01-15T12:00:00',
+ avg_score: 95.0,
+ ...ALL_LIBRARIES_FULL,
+ }),
+ makeSpec({
+ id: 'bar-grouped',
+ title: 'Grouped Bar Chart',
+ updated: '2026-02-20T12:00:00',
+ avg_score: 84.5,
+ matplotlib: 91,
+ seaborn: 78,
+ }),
+ makeSpec({
+ id: 'heatmap-annotated',
+ title: 'Annotated Heatmap',
+ updated: null,
+ avg_score: null,
+ }),
+];
+
+function makeMatrixProps(
+ overrides: Partial> = {}
+): ComponentProps {
+ return {
+ searchText: '',
+ setSearchText: vi.fn(),
+ showIncomplete: false,
+ setShowIncomplete: vi.fn(),
+ showLowScores: false,
+ setShowLowScores: vi.fn(),
+ missingLibrary: '',
+ setMissingLibrary: vi.fn(),
+ filteredSpecs: SAMPLE_SPECS,
+ totalSpecs: SAMPLE_SPECS.length,
+ sortCol: 'updated',
+ sortDir: 'desc',
+ handleSort: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe('HeaderCell', () => {
+ it('renders header text in both default and centered alignments', () => {
+ const { rerender } = render(#);
+ expect(screen.getByText('#')).toBeInTheDocument();
+
+ rerender(mpl);
+ expect(screen.getByText('mpl')).toBeInTheDocument();
+ });
+});
+
+describe('SortableHeader', () => {
+ it('shows the direction indicator only when active and triggers onClick', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const { rerender } = render(
+
+ );
+
+ const button = screen.getByRole('button', { name: 'spec id' });
+ expect(button).not.toHaveTextContent('▲');
+ expect(button).not.toHaveTextContent('▼');
+
+ await user.click(button);
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ rerender();
+ expect(screen.getByRole('button', { name: /spec id/ })).toHaveTextContent('▲');
+
+ rerender();
+ expect(screen.getByRole('button', { name: /spec id/ })).toHaveTextContent('▼');
+ });
+});
+
+describe('SpecMatrix', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders spec rows with implementation counts, scores, links, and fallback dashes', () => {
+ render();
+
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ expect(screen.getByText(`${LIBRARIES.length}/${LIBRARIES.length}`)).toBeInTheDocument();
+ expect(screen.getByText(`2/${LIBRARIES.length}`)).toBeInTheDocument();
+ expect(screen.getByText(`0/${LIBRARIES.length}`)).toBeInTheDocument();
+
+ // Average score formatting in desktop and mobile views
+ expect(screen.getByText('95.0')).toBeInTheDocument();
+ expect(screen.getByText('84.5')).toBeInTheDocument();
+ expect(screen.getByText('avg 95')).toBeInTheDocument();
+ expect(screen.getByText('avg 85')).toBeInTheDocument();
+ expect(screen.getByText('avg —')).toBeInTheDocument();
+
+ // Updated date formatting and missing activity fallback
+ expect(screen.getByText('Jan 15')).toBeInTheDocument();
+ expect(screen.getByText('Feb 20')).toBeInTheDocument();
+ expect(screen.getByText('no activity')).toBeInTheDocument();
+
+ // Links to spec pages and implementation pages
+ const specLinks = screen.getAllByRole('link', { name: 'scatter-basic' });
+ expect(specLinks[0]).toHaveAttribute('href', '/scatter-basic');
+
+ const implLinks = screen.getAllByRole('link', { name: '78' });
+ expect(implLinks[0]).toHaveAttribute('href', '/bar-grouped/python/seaborn');
+ });
+
+ it('shows the direction arrow only on the active sortable header and calls handleSort for each column', async () => {
+ const user = userEvent.setup();
+ const handleSort = vi.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const getHeaders = () => ({
+ id: screen.getByRole('button', { name: /^spec id/ }),
+ title: screen.getByRole('button', { name: /^title/ }),
+ avg_score: screen.getByRole('button', { name: /^avg/ }),
+ updated: screen.getByRole('button', { name: /^updated/ }),
+ });
+
+ let headers = getHeaders();
+ expect(headers.id).toHaveTextContent('▲');
+ expect(headers.title).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.updated).not.toHaveTextContent(/[▲▼]/);
+
+ rerender(
+
+ );
+ headers = getHeaders();
+ expect(headers.title).toHaveTextContent('▼');
+ expect(headers.id).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.updated).not.toHaveTextContent(/[▲▼]/);
+
+ rerender(
+
+ );
+ headers = getHeaders();
+ expect(headers.avg_score).toHaveTextContent('▲');
+ expect(headers.id).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.title).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.updated).not.toHaveTextContent(/[▲▼]/);
+
+ rerender(
+
+ );
+ headers = getHeaders();
+ expect(headers.updated).toHaveTextContent('▼');
+ expect(headers.id).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.title).not.toHaveTextContent(/[▲▼]/);
+ expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/);
+
+ await user.click(headers.id);
+ expect(handleSort).toHaveBeenLastCalledWith('id');
+
+ await user.click(headers.title);
+ expect(handleSort).toHaveBeenLastCalledWith('title');
+
+ await user.click(headers.avg_score);
+ expect(handleSort).toHaveBeenLastCalledWith('avg_score');
+
+ await user.click(headers.updated);
+ expect(handleSort).toHaveBeenLastCalledWith('updated');
+ expect(handleSort).toHaveBeenCalledTimes(4);
+ });
+
+ it('calls filter setters when controls are used', async () => {
+ const user = userEvent.setup();
+ const setSearchText = vi.fn();
+ const setShowIncomplete = vi.fn();
+ const setShowLowScores = vi.fn();
+ const setMissingLibrary = vi.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const incompleteBtn = screen.getByRole('button', {
+ name: new RegExp(`incomplete <${LIBRARIES.length}`, 'i'),
+ });
+ await user.click(incompleteBtn);
+ expect(setShowIncomplete).toHaveBeenCalledTimes(1);
+ const incompleteUpdater = setShowIncomplete.mock.calls[0][0] as (prev: boolean) => boolean;
+ expect(incompleteUpdater(false)).toBe(true);
+ expect(incompleteUpdater(true)).toBe(false);
+
+ const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i });
+ await user.click(lowScoresBtn);
+ expect(setShowLowScores).toHaveBeenCalledTimes(1);
+ const lowScoresUpdater = setShowLowScores.mock.calls[0][0] as (prev: boolean) => boolean;
+ expect(lowScoresUpdater(false)).toBe(true);
+ expect(lowScoresUpdater(true)).toBe(false);
+
+ const missingSelect = screen.getByRole('combobox');
+ await user.selectOptions(missingSelect, 'matplotlib');
+ expect(setMissingLibrary).toHaveBeenCalledWith('matplotlib');
+
+ const searchInput = screen.getByPlaceholderText('search spec id or title...');
+ await user.type(searchInput, 'scatter');
+ expect(setSearchText).toHaveBeenCalledWith('s');
+
+ // Render with active filter flags
+ rerender(
+
+ );
+ expect(screen.getByDisplayValue('scatter')).toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toHaveValue('matplotlib');
+ });
+});
diff --git a/app/src/sections/debug/debugFormat.test.ts b/app/src/sections/debug/debugFormat.test.ts
new file mode 100644
index 00000000000..73111dadf12
--- /dev/null
+++ b/app/src/sections/debug/debugFormat.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+
+import { LIBRARIES } from 'src/constants';
+import {
+ countImpls,
+ formatDateShort,
+ formatNum,
+ pingColor,
+ scoreColor,
+} from 'src/sections/debug/debugFormat';
+import type { SpecStatus } from 'src/sections/debug/SpecMatrix';
+import { colors } from 'src/theme';
+
+function makeSpec(overrides: Partial = {}): SpecStatus {
+ return {
+ id: 'scatter-basic',
+ title: 'Basic Scatter',
+ updated: '2026-01-15T12:00:00',
+ avg_score: null,
+ altair: null,
+ bokeh: null,
+ chartjs: null,
+ d3: null,
+ echarts: null,
+ ggplot2: null,
+ highcharts: null,
+ letsplot: null,
+ makie: null,
+ matplotlib: null,
+ muix: null,
+ plotly: null,
+ plotnine: null,
+ pygal: null,
+ seaborn: null,
+ ...overrides,
+ };
+}
+
+describe('debugFormat', () => {
+ describe('countImpls', () => {
+ it('counts non-null library scores across all libraries', () => {
+ expect(countImpls(makeSpec())).toBe(0);
+ expect(countImpls(makeSpec({ matplotlib: 95, seaborn: 91, d3: 88 }))).toBe(3);
+
+ const fullOverrides = Object.fromEntries(
+ LIBRARIES.map(lib => [lib, 92])
+ ) as Partial;
+ expect(countImpls(makeSpec(fullOverrides))).toBe(LIBRARIES.length);
+ });
+ });
+
+ describe('scoreColor', () => {
+ it('returns muted ink for null and threshold colors for scores', () => {
+ expect(scoreColor(null)).toBe('var(--ink-muted)');
+ expect(scoreColor(90)).toBe(colors.success);
+ expect(scoreColor(96.5)).toBe(colors.success);
+ expect(scoreColor(89.9)).toBe(colors.warning);
+ expect(scoreColor(75)).toBe(colors.warning);
+ expect(scoreColor(74.9)).toBe(colors.error);
+ expect(scoreColor(50)).toBe(colors.error);
+ });
+ });
+
+ describe('formatNum', () => {
+ it('formats millions, thousands, and smaller numbers', () => {
+ expect(formatNum(1_000_000)).toBe('1.0M');
+ expect(formatNum(2_450_000)).toBe('2.5M');
+ expect(formatNum(1_000)).toBe('1.0K');
+ expect(formatNum(15_640)).toBe('15.6K');
+ expect(formatNum(999)).toBe((999).toLocaleString());
+ expect(formatNum(0)).toBe('0');
+ });
+ });
+
+ describe('formatDateShort', () => {
+ it('formats valid ISO dates as short US month and day and returns invalid strings unchanged', () => {
+ expect(formatDateShort('2026-01-15T12:00:00')).toBe('Jan 15');
+ expect(formatDateShort('not-a-date')).toBe('not-a-date');
+ });
+ });
+
+ describe('pingColor', () => {
+ it('maps response latency to success, warning, and error thresholds', () => {
+ expect(pingColor(0)).toBe(colors.success);
+ expect(pingColor(42)).toBe(colors.success);
+ expect(pingColor(74.9)).toBe(colors.success);
+ expect(pingColor(75)).toBe(colors.warning);
+ expect(pingColor(199.9)).toBe(colors.warning);
+ expect(pingColor(200)).toBe(colors.error);
+ expect(pingColor(500)).toBe(colors.error);
+ });
+ });
+});
diff --git a/app/src/sections/map/MapCanvas.test.tsx b/app/src/sections/map/MapCanvas.test.tsx
new file mode 100644
index 00000000000..6f8c5937e87
--- /dev/null
+++ b/app/src/sections/map/MapCanvas.test.tsx
@@ -0,0 +1,284 @@
+import { createRef, forwardRef, useImperativeHandle } from 'react';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { MapForceConfig, MapGraphData } from 'src/hooks/useForceGraphSimulation';
+import type { MapLink, MapNode } from 'src/pages/MapPage.helpers';
+import { render, screen } from 'src/test-utils';
+
+import { MapCanvas } from './MapCanvas';
+
+const mockIsDark = { current: false };
+
+vi.mock('src/hooks/useLayoutContext', () => ({
+ useTheme: () => ({ isDark: mockIsDark.current }),
+}));
+
+type FgProps = Record;
+const lastFgProps: { current: FgProps | null } = { current: null };
+
+interface FgInstance {
+ centerAt: ReturnType;
+ zoom: ReturnType;
+ d3Force: ReturnType;
+ d3ReheatSimulation: ReturnType;
+ refresh: ReturnType;
+ __forcesWired?: boolean;
+}
+
+const fgInstance: FgInstance = {
+ centerAt: vi.fn(),
+ zoom: vi.fn(),
+ d3Force: vi.fn(),
+ d3ReheatSimulation: vi.fn(),
+ refresh: vi.fn(),
+};
+
+vi.mock('react-force-graph-2d', () => ({
+ default: forwardRef((props, ref) => {
+ lastFgProps.current = props;
+ useImperativeHandle(ref, () => fgInstance, []);
+ const data = props.graphData as { nodes: unknown[]; links: unknown[] };
+ return (
+
+ );
+ }),
+}));
+
+function makeNode(
+ overrides: Partial & { id: string; x?: number; y?: number }
+): MapNode & { x?: number; y?: number } {
+ return {
+ title: overrides.id,
+ tags: [],
+ thumbUrl: null,
+ imgs: new Map(),
+ pendingTiers: new Set(),
+ colorBucket: 'scatter',
+ ...overrides,
+ };
+}
+
+const collideForceSentinel: ReturnType = {
+ initialize: () => {},
+ radius: () => collideForceSentinel,
+ iterations: () => collideForceSentinel,
+ strength: () => collideForceSentinel,
+ distance: () => collideForceSentinel,
+};
+const outlierSquashSentinel = Object.assign(() => {}, { initialize: () => {} });
+
+function makeForceConfig(): MapForceConfig {
+ return {
+ cooldownTicks: 300,
+ alphaDecay: 0.015,
+ alphaMin: 0.01,
+ velocityDecay: 0.35,
+ chargeStrength: -50,
+ linkDistance: () => 80,
+ linkStrength: () => 0.2,
+ centerGravity: 0.04,
+ createCollideForce: vi.fn(() => collideForceSentinel),
+ createOutlierSquashForce: vi.fn(() => outlierSquashSentinel),
+ };
+}
+
+function makeProps(overrides: Partial[0]> = {}) {
+ const n1 = makeNode({ id: 'scatter-basic', title: 'Basic Scatter Plot', x: 10, y: 20 });
+ const n2 = makeNode({
+ id: 'line-basic',
+ title: 'Basic Line Chart',
+ colorBucket: 'line',
+ x: 110,
+ y: 120,
+ });
+ const nodes = [n1, n2];
+ const links: MapLink[] = [{ source: 'scatter-basic', target: 'line-basic', weight: 0.6 }];
+ const graphData: MapGraphData = {
+ nodes,
+ links,
+ topTypes: ['scatter', 'line'],
+ typeCounts: new Map([
+ ['scatter', 1],
+ ['line', 1],
+ ]),
+ idf: new Map(),
+ };
+ const fgRef = createRef();
+
+ return {
+ ready: true,
+ fgRef,
+ graphData,
+ size: { w: 800, h: 600 },
+ forceConfig: makeForceConfig(),
+ hoverId: null,
+ hoverType: null,
+ nodeById: new Map(nodes.map(n => [n.id, n])),
+ neighbors: new Map([
+ ['scatter-basic', new Set(['line-basic'])],
+ ['line-basic', new Set(['scatter-basic'])],
+ ]),
+ onNodeClick: vi.fn(),
+ setPinnedId: vi.fn(),
+ setHoverId: vi.fn(),
+ onNodeHover: vi.fn(),
+ markSettled: vi.fn(),
+ handleEngineTick: vi.fn(),
+ settled: false,
+ tickProgress: 0,
+ ...overrides,
+ };
+}
+
+describe('MapCanvas', () => {
+ beforeEach(() => {
+ mockIsDark.current = false;
+ lastFgProps.current = null;
+ fgInstance.centerAt.mockReset();
+ fgInstance.zoom.mockReset();
+ fgInstance.d3Force
+ .mockReset()
+ .mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() });
+ fgInstance.d3ReheatSimulation.mockReset();
+ fgInstance.refresh.mockReset();
+ fgInstance.__forcesWired = undefined;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders nothing before ready is true', () => {
+ render();
+
+ expect(screen.queryByTestId('force-graph-2d')).not.toBeInTheDocument();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('shows the settling overlay with progress while cooling and hides it from assistive tech once settled', () => {
+ const { rerender } = render(
+
+ );
+
+ const status = screen.getByRole('status', { name: 'Computing map layout' });
+ expect(status).toHaveTextContent('map.simulate()');
+
+ const progressBar = screen.getByRole('progressbar', { name: 'Layout computation progress' });
+ expect(progressBar).toHaveAttribute('aria-valuenow', '45');
+
+ mockIsDark.current = true;
+ rerender();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('passes graphData, dimensions, and click/hover/tick handlers to ForceGraph2D', () => {
+ const props = makeProps();
+ render();
+
+ const fg = screen.getByTestId('force-graph-2d');
+ expect(fg).toHaveAttribute('data-node-count', '2');
+ expect(fg).toHaveAttribute('data-link-count', '1');
+
+ const captured = lastFgProps.current!;
+ expect(captured.graphData).toBe(props.graphData);
+ expect(captured.width).toBe(800);
+ expect(captured.height).toBe(600);
+
+ const firstNode = props.graphData.nodes[0];
+ const onNodeHover = captured.onNodeHover as (n: MapNode | null) => void;
+ onNodeHover(firstNode);
+ expect(props.onNodeHover).toHaveBeenCalledWith(firstNode);
+ onNodeHover(null);
+ expect(props.onNodeHover).toHaveBeenCalledWith(null);
+
+ const onNodeClick = captured.onNodeClick as (n: MapNode) => void;
+ onNodeClick(firstNode);
+ expect(props.onNodeClick).toHaveBeenCalledWith(firstNode);
+
+ const onEngineTick = captured.onEngineTick as () => void;
+ onEngineTick();
+ expect(props.handleEngineTick).toHaveBeenCalledTimes(1);
+
+ const nodeLabel = captured.nodeLabel as (n: MapNode) => string;
+ expect(nodeLabel(firstNode)).toBe('Basic Scatter Plot');
+
+ const onBackgroundClick = captured.onBackgroundClick as () => void;
+ onBackgroundClick();
+ expect(props.setPinnedId).toHaveBeenCalledWith(null);
+ expect(props.setHoverId).toHaveBeenCalledWith(null);
+ });
+
+ it('frames the cluster on engine stop and wires d3 forces once in onRenderFramePre', () => {
+ const props = makeProps();
+ render();
+
+ const captured = lastFgProps.current!;
+ const onEngineStop = captured.onEngineStop as () => void;
+ onEngineStop();
+
+ expect(fgInstance.centerAt).toHaveBeenCalledWith(60, 70, 0);
+ expect(fgInstance.zoom).toHaveBeenCalledWith(4.8, 0);
+ expect(props.markSettled).toHaveBeenCalledTimes(1);
+
+ const onRenderFramePre = captured.onRenderFramePre as () => void;
+ onRenderFramePre();
+ expect(fgInstance.d3Force).toHaveBeenCalledWith('charge');
+ expect(fgInstance.d3Force).toHaveBeenCalledWith('link');
+ expect(fgInstance.d3Force).toHaveBeenCalledWith('collide', collideForceSentinel);
+ expect(fgInstance.d3Force).toHaveBeenCalledWith('center');
+ expect(fgInstance.d3Force).toHaveBeenCalledWith('outlier-squash', outlierSquashSentinel);
+ expect(fgInstance.d3ReheatSimulation).toHaveBeenCalledTimes(1);
+
+ // Second frame is a no-op because __forcesWired is now true
+ onRenderFramePre();
+ expect(fgInstance.d3ReheatSimulation).toHaveBeenCalledTimes(1);
+ });
+
+ it('computes linkColor and linkWidth across hoverId and hoverType states', () => {
+ const props = makeProps({ hoverId: 'scatter-basic', hoverType: 'scatter' });
+ const { rerender } = render();
+
+ const linkColor = lastFgProps.current!.linkColor as (l: unknown) => string;
+ const linkWidth = lastFgProps.current!.linkWidth as (l: unknown) => number;
+
+ const involvedColor = linkColor({
+ source: 'scatter-basic',
+ target: { id: 'line-basic' },
+ weight: 0.6,
+ });
+ expect(involvedColor).toMatch(/^#/);
+ expect(
+ linkWidth({ source: 'scatter-basic', target: 'line-basic', weight: 0.6 })
+ ).toBeGreaterThan(linkWidth({ source: 'other-a', target: 'other-b', weight: 0.6 }));
+
+ // Uninvolved link while hoverId is active (hoverType null)
+ rerender();
+ const uninvolvedHoverColor = (lastFgProps.current!.linkColor as (l: unknown) => string)({
+ source: 'line-basic',
+ target: 'other',
+ weight: 0.5,
+ });
+ expect(uninvolvedHoverColor).toContain('rgba');
+
+ // hoverType active without hoverId
+ rerender();
+ const hoverTypeLinkColor = lastFgProps.current!.linkColor as (l: unknown) => string;
+ const intraColor = hoverTypeLinkColor({
+ source: 'scatter-basic',
+ target: 'scatter-basic',
+ weight: 0.5,
+ });
+ const interColor = hoverTypeLinkColor({
+ source: 'scatter-basic',
+ target: 'line-basic',
+ weight: 0.5,
+ });
+ expect(intraColor).not.toBe(interColor);
+ });
+});
diff --git a/app/src/sections/map/MapHoverPanel.test.tsx b/app/src/sections/map/MapHoverPanel.test.tsx
new file mode 100644
index 00000000000..6ee74db40e7
--- /dev/null
+++ b/app/src/sections/map/MapHoverPanel.test.tsx
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { MapPanelData } from 'src/sections/map/useMapSelection';
+import { render, screen } from 'src/test-utils';
+
+import { MapHoverPanel } from './MapHoverPanel';
+
+const mockIsDark = { current: false };
+
+vi.mock('src/hooks/useLayoutContext', () => ({
+ useTheme: () => ({ isDark: mockIsDark.current }),
+}));
+
+function makePanelData(overrides: Partial = {}): MapPanelData {
+ return {
+ title: 'Basic Scatter Plot',
+ plotType: 'scatter',
+ chipColor: '#009E73',
+ tags: ['domain:finance', 'feature:annotated'],
+ previewUrl: 'https://example.com/scatter_800.webp',
+ ...overrides,
+ };
+}
+
+describe('MapHoverPanel', () => {
+ beforeEach(() => {
+ mockIsDark.current = false;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('stays aria-hidden and empty when nothing is hovered or pinned', () => {
+ render();
+
+ expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
+ const hiddenAside = screen.getByRole('complementary', { hidden: true });
+ expect(hiddenAside).toHaveAttribute('aria-hidden', 'true');
+ expect(hiddenAside).toBeEmptyDOMElement();
+ });
+
+ it('reveals the panel with preview image, title, plot type, and tags when a node is hovered', () => {
+ const panelData = makePanelData();
+
+ render();
+
+ const aside = screen.getByRole('complementary');
+ expect(aside).toHaveAttribute('aria-hidden', 'false');
+ expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument();
+ expect(screen.getByText('scatter')).toBeInTheDocument();
+ expect(screen.getByText('domain:finance')).toBeInTheDocument();
+ expect(screen.getByText('feature:annotated')).toBeInTheDocument();
+
+ const img = aside.querySelector('img');
+ expect(img).not.toBeNull();
+ expect(img).toHaveAttribute('src', 'https://example.com/scatter_800.webp');
+ expect(img).toHaveAttribute('alt', '');
+ });
+
+ it('reveals the panel without an img element when a node is pinned and has no previewUrl', () => {
+ mockIsDark.current = true;
+ const panelData = makePanelData({
+ title: 'Pinned Line Plot',
+ plotType: 'line',
+ chipColor: null,
+ tags: [],
+ previewUrl: null,
+ });
+
+ render();
+
+ const aside = screen.getByRole('complementary');
+ expect(aside).toHaveAttribute('aria-hidden', 'false');
+ expect(screen.getByText('Pinned Line Plot')).toBeInTheDocument();
+ expect(screen.getByText('line')).toBeInTheDocument();
+ expect(aside.querySelector('img')).toBeNull();
+ });
+
+ it('omits the plot type row when plotType is null', () => {
+ mockIsDark.current = true;
+ const panelData = makePanelData({
+ title: 'Custom Chart',
+ plotType: null,
+ tags: [],
+ });
+
+ render();
+
+ expect(screen.getByRole('complementary')).toHaveAttribute('aria-hidden', 'false');
+ expect(screen.getByText('Custom Chart')).toBeInTheDocument();
+ expect(screen.queryByText('scatter')).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/map/MapLegend.test.tsx b/app/src/sections/map/MapLegend.test.tsx
new file mode 100644
index 00000000000..0d447290bc8
--- /dev/null
+++ b/app/src/sections/map/MapLegend.test.tsx
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { MapGraphData } from 'src/hooks/useForceGraphSimulation';
+import { fireEvent, render, screen } from 'src/test-utils';
+
+import { MapLegend } from './MapLegend';
+
+function makeGraphData(overrides: Partial = {}): MapGraphData {
+ return {
+ nodes: [],
+ links: [],
+ topTypes: ['scatter', 'bar'],
+ typeCounts: new Map([
+ ['scatter', 14],
+ ['bar', 7],
+ ]),
+ idf: new Map(),
+ ...overrides,
+ };
+}
+
+describe('MapLegend', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders nothing when topTypes is empty', () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: /legend/i })).not.toBeInTheDocument();
+ expect(screen.queryByText('plot_type')).not.toBeInTheDocument();
+ });
+
+ it('renders the active category and each top type with its count', () => {
+ const graphData = makeGraphData({
+ topTypes: ['scatter', 'bar', 'violin'],
+ typeCounts: new Map([
+ ['scatter', 14],
+ ['bar', 7],
+ ]),
+ });
+
+ render(
+
+ );
+
+ expect(screen.getByText('plot_type')).toBeInTheDocument();
+ expect(screen.getByText('scatter')).toBeInTheDocument();
+ expect(screen.getByText('14')).toBeInTheDocument();
+ expect(screen.getByText('bar')).toBeInTheDocument();
+ expect(screen.getByText('7')).toBeInTheDocument();
+ expect(screen.getByText('violin')).toBeInTheDocument();
+ expect(screen.getByText('0')).toBeInTheDocument();
+ });
+
+ it('toggles the mobile legend button between closed and open states', () => {
+ render(
+
+ );
+
+ const toggle = screen.getByRole('button', { name: 'legend ▸' });
+ fireEvent.click(toggle);
+ expect(screen.getByRole('button', { name: 'legend ▾' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'legend ▾' }));
+ expect(screen.getByRole('button', { name: 'legend ▸' })).toBeInTheDocument();
+ });
+
+ it('calls setHoverType on mouse enter and clears it on mouse leave', () => {
+ const setHoverType = vi.fn();
+
+ render(
+
+ );
+
+ const barLabel = screen.getByText('bar');
+ fireEvent.mouseEnter(barLabel);
+ expect(setHoverType).toHaveBeenCalledWith('bar');
+
+ fireEvent.mouseLeave(barLabel);
+ expect(setHoverType).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/app/src/sections/map/MapSearchPill.test.tsx b/app/src/sections/map/MapSearchPill.test.tsx
new file mode 100644
index 00000000000..72a6595c0d8
--- /dev/null
+++ b/app/src/sections/map/MapSearchPill.test.tsx
@@ -0,0 +1,187 @@
+import { useRef, useState } from 'react';
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { SpecMapItem } from 'src/pages/MapPage.helpers';
+import { act, fireEvent, render, screen } from 'src/test-utils';
+
+import { MapSearchPill } from './MapSearchPill';
+
+const mockMatches: SpecMapItem[] = [
+ {
+ id: 'scatter-basic',
+ title: 'Basic Scatter Plot',
+ preview_url_light: null,
+ preview_url_dark: null,
+ quality_score: 90,
+ tags: { plot_type: ['scatter'] },
+ impl_tags: null,
+ },
+ {
+ id: 'line-basic',
+ title: 'Basic Line Chart',
+ preview_url_light: null,
+ preview_url_dark: null,
+ quality_score: 92,
+ tags: { plot_type: ['line'] },
+ impl_tags: null,
+ },
+];
+
+interface HarnessProps {
+ initialQuery?: string;
+ initialOpen?: boolean;
+ initialIdx?: number;
+ matches?: SpecMapItem[];
+ onSelectMatch?: (spec: SpecMapItem) => void;
+}
+
+function SearchPillHarness({
+ initialQuery = '',
+ initialOpen = false,
+ initialIdx = 0,
+ matches = mockMatches,
+ onSelectMatch = vi.fn(),
+}: HarnessProps) {
+ const [searchQuery, setSearchQuery] = useState(initialQuery);
+ const [searchOpen, setSearchOpen] = useState(initialOpen);
+ const [searchIdx, setSearchIdx] = useState(initialIdx);
+ const searchInputRef = useRef(null);
+
+ return (
+
+ );
+}
+
+describe('MapSearchPill', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders the search input and hides the dropdown when closed or when query is blank', () => {
+ const { unmount } = render();
+
+ const input = screen.getByPlaceholderText('specs.search()');
+ expect(input).toHaveValue('scatter');
+ expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument();
+ unmount();
+
+ render();
+ expect(screen.getByPlaceholderText('specs.search()')).toHaveValue(' ');
+ expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument();
+ });
+
+ it('opens on focus, shows matching results, and closes after blur timeout', () => {
+ vi.useFakeTimers();
+ render();
+
+ const input = screen.getByPlaceholderText('specs.search()');
+ fireEvent.focus(input);
+
+ expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument();
+ expect(screen.getByText('scatter-basic')).toBeInTheDocument();
+ expect(screen.getByText('Basic Line Chart')).toBeInTheDocument();
+ expect(screen.getByText('line-basic')).toBeInTheDocument();
+
+ fireEvent.blur(input);
+ act(() => {
+ vi.advanceTimersByTime(150);
+ });
+ expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument();
+ });
+
+ it('shows the empty state when searchOpen is true with a non-empty query and no matches', () => {
+ render();
+
+ expect(screen.getByLabelText('No matches')).toHaveTextContent('results.empty()');
+ });
+
+ it('navigates results with ArrowDown/ArrowUp, selects with Enter, and clears with Escape', () => {
+ vi.useFakeTimers();
+ const selectMatch = vi.fn();
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText('specs.search()');
+ input.focus();
+
+ // Move down to index 1, clamp at the last item, then press Enter
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(selectMatch).toHaveBeenCalledTimes(1);
+ expect(selectMatch).toHaveBeenLastCalledWith(mockMatches[1]);
+
+ // Move back up to index 0, clamp at 0, then press Enter
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(selectMatch).toHaveBeenCalledTimes(2);
+ expect(selectMatch).toHaveBeenLastCalledWith(mockMatches[0]);
+
+ // Typing resets index to 0 and updates the query
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.change(input, { target: { value: 'line' } });
+ expect(input).toHaveValue('line');
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(selectMatch).toHaveBeenLastCalledWith(mockMatches[0]);
+
+ // Escape clears the query and blurs the input
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(input).toHaveValue('');
+ expect(document.activeElement).not.toBe(input);
+ expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument();
+ });
+
+ it('selects a result on mouseDown and updates active index on mouseEnter', () => {
+ const selectMatch = vi.fn();
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText('specs.search()');
+ const secondItem = screen.getByText('Basic Line Chart');
+
+ fireEvent.mouseEnter(secondItem);
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(selectMatch).toHaveBeenCalledWith(mockMatches[1]);
+
+ selectMatch.mockClear();
+ fireEvent.mouseDown(screen.getByText('Basic Scatter Plot'));
+ expect(selectMatch).toHaveBeenCalledWith(mockMatches[0]);
+ });
+
+ it('does not call selectMatch on Enter when there are no matches', () => {
+ const selectMatch = vi.fn();
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText('specs.search()');
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(selectMatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/src/sections/map/MapWeights.test.tsx b/app/src/sections/map/MapWeights.test.tsx
new file mode 100644
index 00000000000..f7091ab2b5e
--- /dev/null
+++ b/app/src/sections/map/MapWeights.test.tsx
@@ -0,0 +1,91 @@
+import { useState } from 'react';
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { DEFAULT_CATEGORY_WEIGHT, TAG_CATEGORIES } from 'src/pages/MapPage.helpers';
+import { fireEvent, render, screen } from 'src/test-utils';
+
+import { MapWeights } from './MapWeights';
+
+describe('MapWeights', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('starts collapsed and opens on toggle click to show all category sliders and threshold', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: 'weights ▸' })).toBeInTheDocument();
+ expect(screen.queryAllByRole('slider')).toHaveLength(0);
+ expect(screen.queryByRole('button', { name: 'reset' })).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'weights ▸' }));
+
+ expect(screen.getByRole('button', { name: 'weights ▾' })).toBeInTheDocument();
+ expect(screen.getAllByRole('slider')).toHaveLength(TAG_CATEGORIES.length + 1);
+ for (const cat of TAG_CATEGORIES) {
+ expect(screen.getByText(cat)).toBeInTheDocument();
+ }
+ expect(screen.getByText('threshold')).toBeInTheDocument();
+ expect(screen.getByText('2.0')).toBeInTheDocument();
+ expect(screen.getByText('0.15')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'weights ▾' }));
+ expect(screen.queryAllByRole('slider')).toHaveLength(0);
+ });
+
+ it('calls setWeights, setMinSim, and resetWeights when controls are used', () => {
+ const setWeightsSpy = vi.fn();
+ const setMinSimSpy = vi.fn();
+ const resetWeightsSpy = vi.fn();
+
+ function Harness() {
+ const [weights, setWeights] = useState>({
+ ...DEFAULT_CATEGORY_WEIGHT,
+ });
+ const [minSim, setMinSim] = useState(0.15);
+ return (
+ {
+ setWeightsSpy(updater);
+ setWeights(updater);
+ }}
+ minSim={minSim}
+ setMinSim={val => {
+ setMinSimSpy(val);
+ setMinSim(val);
+ }}
+ resetWeights={resetWeightsSpy}
+ />
+ );
+ }
+
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'weights ▸' }));
+
+ const sliders = screen.getAllByRole('slider');
+ // First slider corresponds to plot_type (first entry in TAG_CATEGORIES)
+ fireEvent.change(sliders[0], { target: { value: '3.5' } });
+ expect(setWeightsSpy).toHaveBeenCalled();
+ expect(sliders[0]).toHaveAttribute('aria-valuenow', '3.5');
+ expect(screen.getByText('3.5')).toBeInTheDocument();
+
+ // Last slider corresponds to the edge threshold
+ fireEvent.change(sliders[sliders.length - 1], { target: { value: '0.25' } });
+ expect(setMinSimSpy).toHaveBeenCalledWith(0.25);
+ expect(screen.getByText('0.25')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'reset' }));
+ expect(resetWeightsSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/sections/palette/ChromaWheel.test.tsx b/app/src/sections/palette/ChromaWheel.test.tsx
new file mode 100644
index 00000000000..171d4a3cb8c
--- /dev/null
+++ b/app/src/sections/palette/ChromaWheel.test.tsx
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { ChromaWheel } from 'src/sections/palette/ChromaWheel';
+import type { WheelDot } from 'src/sections/palette/types';
+import { render, screen } from 'src/test-utils';
+
+const SAMPLE_DOTS: WheelDot[] = [
+ { hex: '#009E73', L: 0.62, C: 0.13, H: 165.5, name: 'brand green' },
+ { hex: '#C475FD', L: 0.704, C: 0.202, H: 308.9, name: 'lavender' },
+ { hex: '#4467A3', L: 0.516, C: 0.104, H: 260.6 },
+];
+
+const OVERLAY_DOTS: WheelDot[] = [
+ { hex: '#D55E00', L: 0.621, C: 0.17, H: 47.5 },
+ { hex: '#56B4E9', L: 0.735, C: 0.117, H: 236.2 },
+];
+
+function stubCanvas2D() {
+ const putImageData = vi.fn();
+ const createImageData = vi.fn((w: number, h: number) => ({
+ width: w,
+ height: h,
+ colorSpace: 'srgb' as PredefinedColorSpace,
+ data: new Uint8ClampedArray(w * h * 4),
+ }));
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
+ () =>
+ ({
+ createImageData,
+ putImageData,
+ }) as unknown as CanvasRenderingContext2D
+ );
+ return { createImageData, putImageData };
+}
+
+describe('ChromaWheel', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders angle labels, brand star title, and dot titles in full mode and paints the canvas once per size', () => {
+ const { putImageData } = stubCanvas2D();
+ const { rerender } = render();
+
+ expect(screen.getByText('0°')).toBeInTheDocument();
+ expect(screen.getByText('90°')).toBeInTheDocument();
+ expect(screen.getByText('180°')).toBeInTheDocument();
+ expect(screen.getByText('270°')).toBeInTheDocument();
+
+ expect(screen.getByText('★ brand green · #009E73')).toBeInTheDocument();
+ expect(screen.getByText('lavender · #C475FD')).toBeInTheDocument();
+ expect(screen.getByText('#4467A3')).toBeInTheDocument();
+ expect(putImageData).toHaveBeenCalledTimes(1);
+
+ // Re-rendering with the same size and dpr skips the per-pixel repaint.
+ rerender();
+ expect(putImageData).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides angle labels in compact mode and renders overlay palette rings when provided', () => {
+ stubCanvas2D();
+ render();
+
+ expect(screen.queryByText('0°')).not.toBeInTheDocument();
+ expect(screen.queryByText('90°')).not.toBeInTheDocument();
+ expect(screen.getByText('#D55E00')).toBeInTheDocument();
+ expect(screen.getByText('#56B4E9')).toBeInTheDocument();
+ });
+
+ it('renders cleanly with default size when 2D canvas context is unavailable', () => {
+ vi.stubGlobal('devicePixelRatio', 0);
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+
+ render();
+
+ expect(screen.getByText('★ brand green · #009E73')).toBeInTheDocument();
+ expect(screen.getByText('180°')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/CodeBlock.test.tsx b/app/src/sections/palette/CodeBlock.test.tsx
new file mode 100644
index 00000000000..ace6a22badb
--- /dev/null
+++ b/app/src/sections/palette/CodeBlock.test.tsx
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { CodeBlock } from 'src/sections/palette/CodeBlock';
+import { act, fireEvent, render, screen } from 'src/test-utils';
+
+describe('CodeBlock', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('renders the code text and copies it to the clipboard with a 1500 ms feedback reset', async () => {
+ vi.useFakeTimers();
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal('navigator', { clipboard: { writeText } });
+
+ render();
+
+ expect(screen.getByText("const palette = ['#009E73'];")).toBeInTheDocument();
+ const copyButton = screen.getByRole('button', { name: 'copy' });
+
+ await act(async () => {
+ fireEvent.click(copyButton);
+ });
+
+ expect(writeText).toHaveBeenCalledWith("const palette = ['#009E73'];");
+ expect(screen.getByRole('button', { name: 'copied' })).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1499);
+ });
+ expect(screen.getByRole('button', { name: 'copied' })).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument();
+ });
+
+ it('ignores clipboard write failures without flipping to copied state', async () => {
+ const writeText = vi.fn().mockRejectedValue(new Error('Clipboard blocked'));
+ vi.stubGlobal('navigator', { clipboard: { writeText } });
+
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+
+ expect(writeText).toHaveBeenCalledWith("print('imprint')");
+ expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/ContinuousCmapsSection.test.tsx b/app/src/sections/palette/ContinuousCmapsSection.test.tsx
new file mode 100644
index 00000000000..cb78149a400
--- /dev/null
+++ b/app/src/sections/palette/ContinuousCmapsSection.test.tsx
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { ContinuousCmapsSection } from 'src/sections/palette/ContinuousCmapsSection';
+import { render, screen } from 'src/test-utils';
+
+describe('ContinuousCmapsSection', () => {
+ it('renders sequential (imprint_seq) and diverging (imprint_div) colormaps with endpoint stops', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /continuous cmaps/i })).toBeInTheDocument();
+ expect(screen.getByText('imprint_seq')).toBeInTheDocument();
+ expect(screen.getByText('single-polarity · density / magnitude')).toBeInTheDocument();
+ expect(screen.getByText('#009E73 brand-green')).toBeInTheDocument();
+
+ expect(screen.getByText('imprint_div')).toBeInTheDocument();
+ expect(screen.getByText('diverging · correlations / signed deviations')).toBeInTheDocument();
+ expect(screen.getByText('#AE3030 red')).toBeInTheDocument();
+ expect(screen.getByText('theme-adaptive midpoint')).toBeInTheDocument();
+ expect(screen.getAllByText('#4467A3 blue')).toHaveLength(2);
+ expect(screen.getByText('#FAF8F1')).toBeInTheDocument();
+ expect(screen.getByText('#1A1A17')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/CopySnippetsSection.test.tsx b/app/src/sections/palette/CopySnippetsSection.test.tsx
new file mode 100644
index 00000000000..e4bb2ea3f27
--- /dev/null
+++ b/app/src/sections/palette/CopySnippetsSection.test.tsx
@@ -0,0 +1,64 @@
+import { useState } from 'react';
+
+import { describe, expect, it } from 'vitest';
+
+import { type Lang, PALETTE } from 'src/pages/PalettePage.helpers';
+import { CopySnippetsSection } from 'src/sections/palette/CopySnippetsSection';
+import { fireEvent, render, screen } from 'src/test-utils';
+
+function StatefulCopySnippets() {
+ const [lang, setLang] = useState('python');
+ const [oklch, setOklch] = useState(false);
+
+ return (
+
+ );
+}
+
+describe('CopySnippetsSection', () => {
+ it('switches language tabs and updates the snippet text across Python, R, Julia, and JavaScript', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /copy & paste/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText(/from types import SimpleNamespace/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('tab', { name: 'R' }));
+ expect(screen.getByRole('tab', { name: 'R' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText(/IMPRINT <- list\(/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('tab', { name: 'Julia' }));
+ expect(screen.getByRole('tab', { name: 'Julia' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText(/using Colors/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('tab', { name: 'JavaScript' }));
+ expect(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ expect(screen.getByText(/const IMPRINT = \{/)).toBeInTheDocument();
+ });
+
+ it('toggles the OKLCH switch to annotate Python/R/Julia snippets and emit oklch() literals in JavaScript', () => {
+ render();
+
+ const oklchSwitch = screen.getByRole('switch', { name: 'OKLCH' });
+ expect(oklchSwitch).not.toBeChecked();
+ expect(screen.queryByText(/# oklch\(0\.620 0\.130 165\.5\)/)).not.toBeInTheDocument();
+
+ // Toggle OKLCH on in Python mode: adds trailing # oklch(...) comments
+ fireEvent.click(oklchSwitch);
+ expect(oklchSwitch).toBeChecked();
+ expect(screen.getByText(/# oklch\(0\.620 0\.130 165\.5\)/)).toBeInTheDocument();
+
+ // Switch to JavaScript while OKLCH is on: emits bare oklch(...) string values
+ fireEvent.click(screen.getByRole('tab', { name: 'JavaScript' }));
+ expect(screen.getByText(/"oklch\(0\.620 0\.130 165\.5\)"/)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/DeltaEMatrix.test.tsx b/app/src/sections/palette/DeltaEMatrix.test.tsx
new file mode 100644
index 00000000000..d76c1b6fcad
--- /dev/null
+++ b/app/src/sections/palette/DeltaEMatrix.test.tsx
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+
+import { DeltaEMatrix } from 'src/sections/palette/DeltaEMatrix';
+import { render, screen, within } from 'src/test-utils';
+
+const MINI_MATRIX: number[][] = [
+ [0, 4.2, 8.6],
+ [4.2, 0, 18.4],
+ [8.6, 18.4, 0],
+];
+
+describe('DeltaEMatrix', () => {
+ it('renders the title, diagonal dots, rounded ΔE values, and default pairwise tooltips', () => {
+ render();
+
+ expect(screen.getByText('normal vision')).toBeInTheDocument();
+ const table = screen.getByRole('table');
+ const cells = within(table).getAllByRole('cell');
+
+ // 3x3 matrix = 9 data cells; 3 diagonal cells show '·'
+ expect(cells).toHaveLength(9);
+ expect(cells[0]).toHaveTextContent('·');
+ expect(cells[0]).toHaveAttribute('title', 'green');
+
+ // Off-diagonal cells show rounded ΔE and pairwise tooltip
+ expect(cells[1]).toHaveTextContent('4');
+ expect(cells[1]).toHaveAttribute('title', 'green ↔ lavender — ΔE 4.2');
+ expect(cells[2]).toHaveTextContent('9');
+ expect(cells[2]).toHaveAttribute('title', 'green ↔ blue — ΔE 8.6');
+ expect(cells[5]).toHaveTextContent('18');
+ expect(cells[5]).toHaveAttribute('title', 'lavender ↔ blue — ΔE 18.4');
+ });
+
+ it('formats off-diagonal cell tooltips from the per-CVD breakdown when provided', () => {
+ const deut = [
+ [0, 3.5],
+ [3.5, 0],
+ ];
+ const prot = [
+ [0, 6.1],
+ [6.1, 0],
+ ];
+ render(
+
+ );
+
+ const table = screen.getByRole('table');
+ const cells = within(table).getAllByRole('cell');
+ expect(cells[1]).toHaveAttribute('title', 'green ↔ lavender — deut 3.5 · prot 6.1');
+ });
+});
diff --git a/app/src/sections/palette/DeltaESection.test.tsx b/app/src/sections/palette/DeltaESection.test.tsx
new file mode 100644
index 00000000000..3fa0f2c678d
--- /dev/null
+++ b/app/src/sections/palette/DeltaESection.test.tsx
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { DeltaESection } from 'src/sections/palette/DeltaESection';
+import { render, screen, within } from 'src/test-utils';
+
+describe('DeltaESection', () => {
+ it('renders both normal-vision and worst-CVD ΔE matrices alongside the Petroff 2021 legend', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /ΔE colour distance/i })).toBeInTheDocument();
+ expect(screen.getByText('normal vision')).toBeInTheDocument();
+ expect(screen.getByText('worst of deuter / protan / tritan')).toBeInTheDocument();
+
+ const tables = screen.getAllByRole('table');
+ expect(tables).toHaveLength(2);
+
+ // Normal matrix has standard ΔE tooltips; worst-CVD matrix has deut/prot/trit breakdown tooltips
+ expect(within(tables[0]).getByTitle('green ↔ lavender — ΔE 54.1')).toBeInTheDocument();
+ expect(
+ within(tables[1]).getByTitle('green ↔ lavender — deut 36.2 · prot 42.4 · trit 38.6')
+ ).toBeInTheDocument();
+
+ expect(screen.getByText('<5 indistinct')).toBeInTheDocument();
+ expect(screen.getByText('<10 weak')).toBeInTheDocument();
+ expect(screen.getByText('<15 acceptable')).toBeInTheDocument();
+ expect(screen.getByText('≥15 comfortable')).toBeInTheDocument();
+ expect(screen.getByText(/thresholds: Petroff 2021/i)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/HeroSection.test.tsx b/app/src/sections/palette/HeroSection.test.tsx
new file mode 100644
index 00000000000..bca83cb5fae
--- /dev/null
+++ b/app/src/sections/palette/HeroSection.test.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PALETTE } from 'src/pages/PalettePage.helpers';
+import { HeroSection } from 'src/sections/palette/HeroSection';
+import { COMPARISONS } from 'src/sections/palette/paletteHelpers';
+import type { WheelDot } from 'src/sections/palette/types';
+import { fireEvent, render, screen } from 'src/test-utils';
+
+const IMPRINT_DOTS: WheelDot[] = PALETTE.map(s => ({
+ hex: s.hex,
+ L: s.L,
+ C: s.C,
+ H: s.H,
+ name: s.name,
+}));
+
+function StatefulHero() {
+ const [wheelOpen, setWheelOpen] = useState(false);
+ const [compareId, setCompareId] = useState(null);
+ const overlayDots = COMPARISONS.find(c => c.id === compareId)?.hexes;
+
+ return (
+
+ );
+}
+
+describe('HeroSection', () => {
+ beforeEach(() => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('expands and collapses the hue–chroma wheel and updates aria-expanded', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /anyplot's imprint palette/i })).toBeInTheDocument();
+ expect(screen.getByText('compare ▸')).toBeInTheDocument();
+
+ const expandButton = screen.getByRole('button', {
+ name: 'Expand the hue–chroma wheel to compare palettes',
+ });
+ expect(expandButton).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(expandButton);
+
+ const collapseButton = screen.getByRole('button', {
+ name: 'Collapse the hue–chroma wheel',
+ });
+ expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('click to close')).toBeInTheDocument();
+
+ fireEvent.click(collapseButton);
+ expect(
+ screen.getByRole('button', {
+ name: 'Expand the hue–chroma wheel to compare palettes',
+ })
+ ).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.getByText('compare ▸')).toBeInTheDocument();
+ });
+
+ it('picks and unpicks a comparison palette and updates aria-pressed and overlay rings', () => {
+ render();
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: 'Expand the hue–chroma wheel to compare palettes',
+ })
+ );
+
+ const okabeButton = screen.getByRole('button', { name: 'Okabe-Ito' });
+ const tolButton = screen.getByRole('button', { name: 'Tol muted' });
+ expect(okabeButton).toHaveAttribute('aria-pressed', 'false');
+ expect(tolButton).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.queryByText(/rings =/i)).not.toBeInTheDocument();
+
+ // Pick Okabe-Ito
+ fireEvent.click(okabeButton);
+ expect(okabeButton).toHaveAttribute('aria-pressed', 'true');
+ expect(tolButton).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.getByText(/· rings = Okabe-Ito/)).toBeInTheDocument();
+ expect(screen.getByText('#D55E00')).toBeInTheDocument();
+
+ // Switch directly to Tol muted
+ fireEvent.click(tolButton);
+ expect(tolButton).toHaveAttribute('aria-pressed', 'true');
+ expect(okabeButton).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.getByText(/· rings = Tol muted/)).toBeInTheDocument();
+
+ // Unpick Tol muted
+ fireEvent.click(tolButton);
+ expect(tolButton).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.queryByText(/rings =/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/LineageSection.test.tsx b/app/src/sections/palette/LineageSection.test.tsx
new file mode 100644
index 00000000000..f7435d3e40c
--- /dev/null
+++ b/app/src/sections/palette/LineageSection.test.tsx
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { LineageSection } from 'src/sections/palette/LineageSection';
+import { render, screen } from 'src/test-utils';
+
+describe('LineageSection', () => {
+ it('renders the three palette history entries and reference links', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /lineage/i })).toBeInTheDocument();
+ expect(screen.getByText('version timeline')).toBeInTheDocument();
+
+ expect(screen.getByText('v3 — imprint (current)')).toBeInTheDocument();
+ expect(screen.getByText('v1 — variant D ("balanced")')).toBeInTheDocument();
+ expect(screen.getByText('v0 — Okabe-Ito')).toBeInTheDocument();
+
+ const petroffLink = screen.getByRole('link', { name: 'Petroff 2021 (arXiv)' });
+ expect(petroffLink).toHaveAttribute('href', 'https://arxiv.org/abs/2107.02270');
+
+ const okabeLink = screen.getByRole('link', { name: 'jfly.uni-koeln.de' });
+ expect(okabeLink).toHaveAttribute('href', 'https://jfly.uni-koeln.de/color/');
+
+ const draculaLink = screen.getByRole('link', { name: 'dracula-palette' });
+ expect(draculaLink).toHaveAttribute('href', 'https://anselmoo.github.io/dracula-palette/');
+ });
+});
diff --git a/app/src/sections/palette/PaletteGridSection.test.tsx b/app/src/sections/palette/PaletteGridSection.test.tsx
new file mode 100644
index 00000000000..5b73742d368
--- /dev/null
+++ b/app/src/sections/palette/PaletteGridSection.test.tsx
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { PALETTE } from 'src/pages/PalettePage.helpers';
+import { PaletteGridSection } from 'src/sections/palette/PaletteGridSection';
+import { fireEvent, render, screen, within } from 'src/test-utils';
+
+describe('PaletteGridSection', () => {
+ it('calls setSort("cvd") and setSort("imprint") from the sort buttons and shows the trade-off table when sort="cvd"', () => {
+ const setSort = vi.fn();
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByRole('heading', { name: /the 8 categorical hues/i })).toBeInTheDocument();
+ expect(screen.queryByText(/why use CVD-optimal sort\?/i)).not.toBeInTheDocument();
+
+ const buttons = screen.getAllByRole('button', { name: /Copy #[0-9A-F]{6}/i });
+ expect(buttons).toHaveLength(8);
+ expect(buttons[0]).toHaveAccessibleName('Copy #009E73 (brand green) to clipboard');
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'CVD-optimal (max ΔE under colorblindness)' })
+ );
+ expect(setSort).toHaveBeenCalledWith('cvd');
+
+ fireEvent.click(screen.getByRole('button', { name: 'imprint (default)' }));
+ expect(setSort).toHaveBeenCalledWith('imprint');
+
+ rerender(
+
+ );
+
+ expect(screen.getByText(/why use CVD-optimal sort\?/i)).toBeInTheDocument();
+ const table = screen.getByRole('table');
+ expect(within(table).getByText('21.45 (+5.11)')).toBeInTheDocument();
+ expect(within(table).getByText('36.19 (=)')).toBeInTheDocument();
+ });
+
+ it('calls copyHex with the clicked swatch hex and shows copied ✓ only on copiedHex', () => {
+ const copyHex = vi.fn();
+ const { rerender } = render(
+
+ );
+
+ const brandButton = screen.getByRole('button', {
+ name: 'Copy #009E73 (brand green) to clipboard',
+ });
+ expect(within(brandButton).getByText('#009E73')).toBeInTheDocument();
+
+ fireEvent.click(brandButton);
+ expect(copyHex).toHaveBeenCalledWith('#009E73');
+
+ rerender(
+
+ );
+
+ expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument();
+ expect(screen.getAllByText('copied ✓')).toHaveLength(1);
+ expect(screen.getByText('#C475FD')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/sections/palette/SemanticAnchorsSection.test.tsx b/app/src/sections/palette/SemanticAnchorsSection.test.tsx
new file mode 100644
index 00000000000..2961c7dcbf5
--- /dev/null
+++ b/app/src/sections/palette/SemanticAnchorsSection.test.tsx
@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { SemanticAnchorsSection } from 'src/sections/palette/SemanticAnchorsSection';
+import { fireEvent, render, screen } from 'src/test-utils';
+
+describe('SemanticAnchorsSection', () => {
+ it('renders amber, adaptive neutral, and adaptive muted anchors with their swatch buttons', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /semantic anchors/i })).toBeInTheDocument();
+ expect(screen.getByText('palette.amber')).toBeInTheDocument();
+ expect(screen.getByText('palette.neutral — adaptive')).toBeInTheDocument();
+ expect(screen.getByText('palette.muted — adaptive')).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('button', { name: 'Copy #DDCC77 (amber) to clipboard' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Copy #1A1A17 (neutral) to clipboard' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Copy #F0EFE8 (neutral) to clipboard' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Copy #6B6A63 (muted) to clipboard' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Copy #A8A79F (muted) to clipboard' })
+ ).toBeInTheDocument();
+ });
+
+ it('calls copyHex with the clicked anchor hex and shows copied ✓ only on the matching swatch', () => {
+ const copyHex = vi.fn();
+ const { rerender } = render();
+
+ const amberSwatch = screen.getByRole('button', {
+ name: 'Copy #DDCC77 (amber) to clipboard',
+ });
+ expect(amberSwatch).toHaveTextContent('#DDCC77');
+
+ fireEvent.click(amberSwatch);
+ expect(copyHex).toHaveBeenCalledWith('#DDCC77');
+
+ rerender();
+ expect(amberSwatch).toHaveTextContent('copied ✓');
+ expect(screen.getAllByText('copied ✓')).toHaveLength(1);
+ expect(
+ screen.getByRole('button', { name: 'Copy #1A1A17 (neutral) to clipboard' })
+ ).toHaveTextContent('#1A1A17');
+ });
+});
diff --git a/app/src/sections/palette/WcagContrastSection.test.tsx b/app/src/sections/palette/WcagContrastSection.test.tsx
new file mode 100644
index 00000000000..d94c39dc5ff
--- /dev/null
+++ b/app/src/sections/palette/WcagContrastSection.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import { WcagContrastSection } from 'src/sections/palette/WcagContrastSection';
+import { render, screen } from 'src/test-utils';
+
+// jsdom drops a `border` that contains `var(...)` from the computed style, so
+// read the border the swatch's own stylesheet rule declares instead.
+function declaredSwatchBorder(hex: string): string | undefined {
+ const swatch = screen.getByRole('cell', { name: hex }).firstElementChild;
+ if (!(swatch instanceof HTMLElement)) return undefined;
+ const borders = Array.from(document.styleSheets)
+ .flatMap(sheet => Array.from(sheet.cssRules))
+ .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
+ .filter(rule => swatch.matches(rule.selectorText))
+ .map(rule => rule.style.getPropertyValue('border'))
+ .filter(Boolean);
+ return borders[borders.length - 1];
+}
+
+describe('WcagContrastSection', () => {
+ it('renders the WCAG contrast table with pass (✓) and fail (✗) ratios on cream and on dark', () => {
+ render();
+
+ expect(screen.getByRole('heading', { name: /WCAG contrast/i })).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'on cream' })).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'on dark' })).toBeInTheDocument();
+
+ // Brand green passes both; matte red passes cream, fails dark; lavender fails cream, passes dark
+ expect(screen.getByText('3.08:1 ✓')).toBeInTheDocument();
+ expect(screen.getByText('5.48:1 ✓')).toBeInTheDocument();
+ expect(screen.getByText('5.79:1 ✓')).toBeInTheDocument();
+ expect(screen.getByText('2.92:1 ✗')).toBeInTheDocument();
+ expect(screen.getByText('2.59:1 ✗')).toBeInTheDocument();
+ expect(screen.getByText('6.53:1 ✓')).toBeInTheDocument();
+ expect(screen.getByText('1.46:1 ✗')).toBeInTheDocument();
+ expect(screen.getByText('11.59:1 ✓')).toBeInTheDocument();
+ });
+
+ it('names each row by hex and palette name', () => {
+ render();
+
+ expect(screen.getByRole('cell', { name: '#C475FD' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'lavender' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: '#AE3030' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'matte-red' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: '#DDCC77' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'amber' })).toBeInTheDocument();
+ });
+
+ it('strokes the swatches that fail on the current background with --ink', () => {
+ const { rerender } = render();
+ // On cream, lavender fails and matte red passes.
+ expect(declaredSwatchBorder('#C475FD')).toBe('1px solid var(--ink)');
+ expect(declaredSwatchBorder('#AE3030')).toBe('1px solid var(--rule)');
+
+ rerender();
+ // On dark, the two swap.
+ expect(declaredSwatchBorder('#C475FD')).toBe('1px solid var(--rule)');
+ expect(declaredSwatchBorder('#AE3030')).toBe('1px solid var(--ink)');
+ });
+});
diff --git a/app/src/sections/palette/paletteHelpers.test.ts b/app/src/sections/palette/paletteHelpers.test.ts
new file mode 100644
index 00000000000..bb44a4d85ad
--- /dev/null
+++ b/app/src/sections/palette/paletteHelpers.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ COMPARISONS,
+ DE_T_BAD,
+ DE_T_OK,
+ DE_T_WARN,
+ deltaECellBg,
+ oklchToRgb,
+ starPoints,
+ textOn,
+} from 'src/sections/palette/paletteHelpers';
+
+describe('paletteHelpers', () => {
+ describe('thresholds and comparison palettes', () => {
+ it('exports the Petroff 2021 ΔE thresholds in ascending order', () => {
+ expect([DE_T_BAD, DE_T_WARN, DE_T_OK]).toEqual([5, 10, 15]);
+ });
+
+ it('exports the four comparison palettes with valid hex and OKLCH entries', () => {
+ expect(COMPARISONS.map(c => c.id)).toEqual(['okabe', 'tol', 'set2', 'prev']);
+ for (const comp of COMPARISONS) {
+ expect(comp.name.length).toBeGreaterThan(0);
+ expect(comp.hexes.length).toBeGreaterThanOrEqual(7);
+ for (const swatch of comp.hexes) {
+ expect(swatch.hex).toMatch(/^#[0-9A-F]{6}$/);
+ expect(swatch.L).toBeGreaterThan(0);
+ expect(swatch.L).toBeLessThanOrEqual(1);
+ expect(swatch.C).toBeGreaterThanOrEqual(0);
+ expect(swatch.H).toBeGreaterThanOrEqual(0);
+ expect(swatch.H).toBeLessThan(360);
+ }
+ }
+ });
+ });
+
+ describe('oklchToRgb', () => {
+ it('maps black (L=0, C=0) and white (L=1, C=0) to the sRGB endpoints', () => {
+ expect(oklchToRgb(0, 0, 0)).toEqual([0, 0, 0]);
+ expect(oklchToRgb(1, 0, 0)).toEqual([255, 255, 255]);
+ });
+
+ it('produces neutral grey with equal R, G, B channels when chroma is zero', () => {
+ const [r, g, b] = oklchToRgb(0.5, 0, 180);
+ expect(r).toBe(g);
+ expect(g).toBe(b);
+ expect(r).toBeGreaterThan(0);
+ expect(r).toBeLessThan(255);
+ });
+
+ it('approximates the brand-green sRGB triplet (#009E73) at its OKLCH coordinates', () => {
+ const [r, g, b] = oklchToRgb(0.62, 0.13, 165.5);
+ expect(r).toBeLessThanOrEqual(2);
+ expect(g).toBeGreaterThanOrEqual(156);
+ expect(g).toBeLessThanOrEqual(160);
+ expect(b).toBeGreaterThanOrEqual(113);
+ expect(b).toBeLessThanOrEqual(117);
+ });
+
+ it('clamps out-of-gamut high-chroma coordinates into 0..255 integers', () => {
+ const rgb = oklchToRgb(0.85, 0.4, 30);
+ for (const ch of rgb) {
+ expect(Number.isInteger(ch)).toBe(true);
+ expect(ch).toBeGreaterThanOrEqual(0);
+ expect(ch).toBeLessThanOrEqual(255);
+ }
+ });
+
+ it('is periodic modulo 360 degrees in hue', () => {
+ expect(oklchToRgb(0.72, 0.14, 0)).toEqual(oklchToRgb(0.72, 0.14, 360));
+ });
+ });
+
+ describe('starPoints', () => {
+ it('builds a 10-vertex alternating star starting at 12 o’clock', () => {
+ const points = starPoints(100, 100, 10, 5).split(' ');
+ expect(points).toHaveLength(10);
+ // Vertex 0: 12 o'clock outer tip (cx, cy - rOuter)
+ expect(points[0]).toBe('100.00,90.00');
+ // Vertex 5: 6 o'clock inner valley (cx, cy + rInner)
+ expect(points[5]).toBe('100.00,105.00');
+ });
+ });
+
+ describe('textOn', () => {
+ it('returns dark ink (#1A1A17) on light swatches and light ink (#F0EFE8) on dark swatches', () => {
+ expect(textOn('#FFFFFF')).toBe('#1A1A17');
+ expect(textOn('#F5F3EC')).toBe('#1A1A17');
+ expect(textOn('#DDCC77')).toBe('#1A1A17');
+ expect(textOn('#000000')).toBe('#F0EFE8');
+ expect(textOn('#1A1A17')).toBe('#F0EFE8');
+ expect(textOn('#4467A3')).toBe('#F0EFE8');
+ expect(textOn('009E73')).toBe('#F0EFE8');
+ });
+ });
+
+ describe('deltaECellBg', () => {
+ it('returns transparent on the diagonal and for comfortable distances (>= 15)', () => {
+ expect(deltaECellBg(0)).toBe('transparent');
+ expect(deltaECellBg(-1)).toBe('transparent');
+ expect(deltaECellBg(DE_T_OK)).toBe('transparent');
+ expect(deltaECellBg(25)).toBe('transparent');
+ });
+
+ it('returns the red, amber, and yellow tints across the three warning bands', () => {
+ expect(deltaECellBg(0.1)).toBe('rgba(174, 48, 48, 0.26)');
+ expect(deltaECellBg(DE_T_BAD - 0.01)).toBe('rgba(174, 48, 48, 0.26)');
+ expect(deltaECellBg(DE_T_BAD)).toBe('rgba(189, 130, 51, 0.22)');
+ expect(deltaECellBg(DE_T_WARN - 0.01)).toBe('rgba(189, 130, 51, 0.22)');
+ expect(deltaECellBg(DE_T_WARN)).toBe('rgba(221, 204, 119, 0.18)');
+ expect(deltaECellBg(DE_T_OK - 0.01)).toBe('rgba(221, 204, 119, 0.18)');
+ });
+ });
+});
diff --git a/changelog.d/section-tests.md b/changelog.d/section-tests.md
new file mode 100644
index 00000000000..86d8aad43f3
--- /dev/null
+++ b/changelog.d/section-tests.md
@@ -0,0 +1,14 @@
+### Added
+
+- **Render and unit tests for the palette, debug, and map sections.** Vitest
+ suites under `app/src/sections/palette/`, `app/src/sections/debug/`, and
+ `app/src/sections/map/` cover the components split out of `PalettePage`,
+ `DebugPage`, and `MapPage`: the hue–chroma wheel, comparison palettes,
+ snippet tabs, the WCAG swatch stroke per theme, the spec matrix headers and
+ filters, feedback triage, and the map legend, hover panel, search pill,
+ force weights, and canvas shell. The state that lives in the pages is tested
+ on the real pages: `PalettePage.test.tsx` covers the swatch copy reset and
+ the CVD sort order, and `DebugPage.test.tsx` the spec sort orders, the
+ incomplete and low-score filters, and the feedback status PATCH with its
+ revert. Every behaviour the review named was checked by breaking it on
+ purpose and watching a test fail. (#12470)