diff --git a/packages/app/cypress/e2e/overview.cy.ts b/packages/app/cypress/e2e/overview.cy.ts
index 86b34e31..f8aa6174 100644
--- a/packages/app/cypress/e2e/overview.cy.ts
+++ b/packages/app/cypress/e2e/overview.cy.ts
@@ -24,6 +24,8 @@ const MATRIX_ROWS = 8;
const AGENTX = 'agentx';
const AGENTX_LABEL = 'Long Context Multi-Turn Realistic Agentic Scenario (AgentX)';
const AGENTX_LABEL_ZH = '长上下文多轮真实智能体场景(AgentX)';
+/** Shared by both locales: the scenario is named after its acronym. */
+const AGENTX_SHORT = 'AgentX';
const PAGE_TITLE = 'Inference Cost per Million Tokens';
const PAGE_TITLE_ZH = '推理每百万 token 成本';
@@ -58,6 +60,21 @@ function expectNoHorizontalScroller(testId: string) {
});
}
+/**
+ * The row header shows the acronym so the 22%-wide model column stays one line,
+ * and keeps the full scenario name for assistive tech and the hover title. Both
+ * strings are in the cell, so assert the two layers rather than their
+ * concatenation. Scenarios already named by a short label render one node.
+ */
+function expectAgentxScenario(fullLabel: string) {
+ cy.get('[data-testid="overview-model-scenario"]')
+ .should('have.attr', 'title', fullLabel)
+ .within(() => {
+ cy.get('.sr-only').should('have.text', fullLabel);
+ cy.get('[aria-hidden="true"]').should('have.text', AGENTX_SHORT);
+ });
+}
+
/** Visible dates and snapshot framing must be gone; evidence stays in labels. */
function expectNoVisibleDatesOrSnapshot() {
cy.get('[data-testid="overview-pair-evidence-date"]').should('not.exist');
@@ -407,8 +424,11 @@ describe('Overview page', () => {
cy.viewport(1280, 900);
cy.visit('/overview');
- cy.get('[data-testid="overview-page"]')
- .children('[data-testid="overview-comparison-switcher"]')
+ // The tabs sit inside the surface handed to the Fullscreen API so that
+ // presenting keeps them with the matrix, which costs them their old spot as
+ // a direct child of the page.
+ cy.get('[data-testid="overview-presentation-surface"]')
+ .find('[data-testid="overview-comparison-switcher"]')
.should('have.length', 1)
.and('have.class', 'justify-center');
cy.get('[data-testid="overview-comparison-switcher"]')
@@ -603,6 +623,43 @@ describe('Overview page', () => {
}
});
+ // Clicking the control, not just building its href: the matrix reads from a
+ // client data cache, and a cache keyed without the row params moves the
+ // address bar while leaving every row on screen.
+ it('narrows the matrix when the row filter is clicked in either comparison mode', () => {
+ cy.viewport(1280, 900);
+
+ for (const [href, attribute, key, sentence] of [
+ ['/overview?compare=30d', 'data-overview-row-scope', 'rows=changed', 'no 30-day change'],
+ [
+ '/overview',
+ 'data-overview-hardware-row-scope',
+ 'hwrows=priced',
+ 'no result on any platform',
+ ],
+ ] as const) {
+ cy.visit(href);
+ cy.get('[data-testid="overview-desktop-model"]').should('have.length', MATRIX_ROWS);
+
+ cy.get(`a[${attribute}]`)
+ .should('contain.text', 'Hide ')
+ .invoke('text')
+ .then((label) => {
+ const hidden = Number(/\d+/.exec(label)?.[0]);
+ expect(hidden, `hidden row count in "${label}"`).to.be.greaterThan(0);
+
+ cy.get(`a[${attribute}]`).click();
+ cy.location('search').should('contain', key);
+ cy.get('[data-testid="overview-desktop-model"]').should(
+ 'have.length',
+ MATRIX_ROWS - hidden,
+ );
+ // The action label names the click, so it flips once the scope lands.
+ cy.get(`a[${attribute}]`).should('contain.text', `Show ${hidden} rows with ${sentence}`);
+ });
+ }
+ });
+
it('defaults to community engine scope and switches with canonical links preserving tier and locale', () => {
cy.viewport(1280, 900);
cy.visit('/overview');
@@ -632,7 +689,7 @@ describe('Overview page', () => {
});
desktopModel('GLM-5.2').within(() => {
- cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL);
+ expectAgentxScenario(AGENTX_LABEL);
cy.get('[data-testid="overview-pair-missing"]').should('have.length', 5);
});
cy.get(
@@ -872,9 +929,9 @@ describe('Overview page', () => {
cy.get('[data-testid="overview-desktop-matrix"]').should('contain.text', label);
}
for (const model of ['Kimi-K3', 'GLM-5.2']) {
- desktopModel(model)
- .find('[data-testid="overview-model-scenario"]')
- .should('have.text', AGENTX_LABEL);
+ desktopModel(model).within(() => {
+ expectAgentxScenario(AGENTX_LABEL);
+ });
}
for (const model of ['DeepSeek-V4-Pro', 'MiniMax-M3', 'Qwen-3.5-397B-A17B']) {
desktopModel(model, SINGLE_TURN)
@@ -897,7 +954,7 @@ describe('Overview page', () => {
});
desktopModel('DeepSeek-V4-Pro', AGENTX).within(() => {
- cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL);
+ expectAgentxScenario(AGENTX_LABEL);
cy.contains('DeepSeek V4 Pro 1.6T').should('exist');
// Priced from the AgentX rows alone — the single-turn sweep never leaks in.
cy.get(
@@ -1412,14 +1469,14 @@ describe('Overview page', () => {
.and('have.attr', 'title', '缺少可比较的 B200 基线');
});
desktopModel('GLM-5.2').within(() => {
- cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL_ZH);
+ expectAgentxScenario(AGENTX_LABEL_ZH);
cy.get('[data-testid="overview-pair-missing"]').should('have.length', 5);
platform('b300')
.find('[data-testid="overview-pair-missing"]')
.should('contain.text', '该场景暂无数据');
});
desktopModel('DeepSeek-V4-Pro', AGENTX).within(() => {
- cy.get('[data-testid="overview-model-scenario"]').should('have.text', AGENTX_LABEL_ZH);
+ expectAgentxScenario(AGENTX_LABEL_ZH);
cy.get(
'[data-testid="overview-pair-value"][data-hardware="b200"] [data-testid="overview-cost-evidence-link"]',
).should('have.text', '$0.064');
diff --git a/packages/app/src/app/api/v1/overview/route.test.ts b/packages/app/src/app/api/v1/overview/route.test.ts
index f0415cf3..32790557 100644
--- a/packages/app/src/app/api/v1/overview/route.test.ts
+++ b/packages/app/src/app/api/v1/overview/route.test.ts
@@ -37,12 +37,22 @@ describe('GET /api/v1/overview', () => {
mockGetOverviewPageData.mockResolvedValueOnce(data);
const response = await GET(
- request('/api/v1/overview?tier=75&engine=all&compare=30d&ref=b300&models=all'),
+ request(
+ '/api/v1/overview?tier=75&engine=all&compare=30d&ref=b300&models=all&rows=changed&hwrows=priced',
+ ),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual(data);
- expect(mockGetOverviewPageData).toHaveBeenCalledWith(75, 'all', 'history', 'b300', 'all');
+ expect(mockGetOverviewPageData).toHaveBeenCalledWith(
+ 75,
+ 'all',
+ 'history',
+ 'b300',
+ 'all',
+ 'changed',
+ 'priced',
+ );
expect(mockCachedJson).toHaveBeenCalledWith(data);
});
@@ -50,7 +60,9 @@ describe('GET /api/v1/overview', () => {
mockGetOverviewPageData.mockResolvedValueOnce({ models: [] });
await GET(
- request('/api/v1/overview?tier=999&engine=vendor&compare=weekly&ref=h100&models=inactive'),
+ request(
+ '/api/v1/overview?tier=999&engine=vendor&compare=weekly&ref=h100&models=inactive&rows=some&hwrows=blank',
+ ),
);
expect(mockGetOverviewPageData).toHaveBeenCalledWith(
@@ -59,6 +71,8 @@ describe('GET /api/v1/overview', () => {
'hardware',
'b200',
'default',
+ 'all',
+ 'all',
);
});
diff --git a/packages/app/src/app/api/v1/overview/route.ts b/packages/app/src/app/api/v1/overview/route.ts
index ba681916..3842b87c 100644
--- a/packages/app/src/app/api/v1/overview/route.ts
+++ b/packages/app/src/app/api/v1/overview/route.ts
@@ -4,8 +4,10 @@ import { cachedJson } from '@/lib/api-cache';
import {
resolveOverviewComparisonMode,
resolveOverviewEngineScope,
+ resolveOverviewHardwareRowScope,
resolveOverviewModelScope,
resolveOverviewReferenceHardware,
+ resolveOverviewRowScope,
resolveOverviewTier,
} from '@/lib/overview-data';
import { getOverviewPageData } from '@/lib/overview-data.server';
@@ -22,6 +24,8 @@ export async function GET(request: NextRequest) {
resolveOverviewComparisonMode(params.get('compare') ?? undefined),
resolveOverviewReferenceHardware(params.get('ref') ?? undefined),
resolveOverviewModelScope(params.get('models') ?? undefined),
+ resolveOverviewRowScope(params.get('rows') ?? undefined),
+ resolveOverviewHardwareRowScope(params.get('hwrows') ?? undefined),
);
return cachedJson(data);
} catch (error) {
diff --git a/packages/app/src/app/overview/page.tsx b/packages/app/src/app/overview/page.tsx
index a0c04446..ae76ab5a 100644
--- a/packages/app/src/app/overview/page.tsx
+++ b/packages/app/src/app/overview/page.tsx
@@ -7,8 +7,10 @@ import { enAlternates } from '@/lib/i18n';
import {
resolveOverviewComparisonMode,
resolveOverviewEngineScope,
+ resolveOverviewHardwareRowScope,
resolveOverviewModelScope,
resolveOverviewReferenceHardware,
+ resolveOverviewRowScope,
resolveOverviewTier,
} from '@/lib/overview-data';
import { getOverviewPageData } from '@/lib/overview-data.server';
@@ -47,6 +49,8 @@ export default async function OverviewPage({ searchParams }: Props) {
resolveOverviewComparisonMode(sp.compare),
resolveOverviewReferenceHardware(sp.ref),
resolveOverviewModelScope(sp.models),
+ resolveOverviewRowScope(sp.rows),
+ resolveOverviewHardwareRowScope(sp.hwrows),
);
return ;
}
diff --git a/packages/app/src/app/zh/overview/page.tsx b/packages/app/src/app/zh/overview/page.tsx
index 524d15db..742dcc86 100644
--- a/packages/app/src/app/zh/overview/page.tsx
+++ b/packages/app/src/app/zh/overview/page.tsx
@@ -7,8 +7,10 @@ import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n';
import {
resolveOverviewComparisonMode,
resolveOverviewEngineScope,
+ resolveOverviewHardwareRowScope,
resolveOverviewModelScope,
resolveOverviewReferenceHardware,
+ resolveOverviewRowScope,
resolveOverviewTier,
} from '@/lib/overview-data';
import { getOverviewPageData } from '@/lib/overview-data.server';
@@ -48,6 +50,8 @@ export default async function ZhOverviewPage({ searchParams }: Props) {
resolveOverviewComparisonMode(sp.compare),
resolveOverviewReferenceHardware(sp.ref),
resolveOverviewModelScope(sp.models),
+ resolveOverviewRowScope(sp.rows),
+ resolveOverviewHardwareRowScope(sp.hwrows),
);
return ;
}
diff --git a/packages/app/src/components/overview/overview-nav-link.tsx b/packages/app/src/components/overview/overview-nav-link.tsx
index d97434cb..6b9b5dbc 100644
--- a/packages/app/src/components/overview/overview-nav-link.tsx
+++ b/packages/app/src/components/overview/overview-nav-link.tsx
@@ -12,7 +12,7 @@ import { useOverviewNavigation } from './overview-navigation';
const PREFETCH_DWELL_MS = 120;
interface OverviewNavAnalytics {
- control: 'comparison' | 'engine' | 'models' | 'tier';
+ control: 'comparison' | 'engine' | 'hwrows' | 'models' | 'rows' | 'tier';
value: string;
}
diff --git a/packages/app/src/components/overview/overview-navigation.test.tsx b/packages/app/src/components/overview/overview-navigation.test.tsx
index 015fcd25..c301c56c 100644
--- a/packages/app/src/components/overview/overview-navigation.test.tsx
+++ b/packages/app/src/components/overview/overview-navigation.test.tsx
@@ -33,6 +33,8 @@ let root: Root;
let selectTier: (() => void) | undefined;
let selectEngine: (() => void) | undefined;
let selectReference: (() => void) | undefined;
+let selectRowScope: (() => void) | undefined;
+let selectHardwareRowScope: (() => void) | undefined;
let prefetchTier: (() => void) | undefined;
function pageData(tier: OverviewTier): OverviewPageData {
@@ -43,6 +45,10 @@ function pageData(tier: OverviewTier): OverviewPageData {
comparisonMode: 'hardware',
referenceHardware: 'b200',
modelScope: 'default',
+ rowScope: 'all',
+ hardwareRowScope: 'all',
+ unchangedRowCount: 0,
+ emptyRowCount: 0,
historicalWindow: null,
};
}
@@ -54,6 +60,8 @@ function Probe() {
selectTier = () => navigation.push('/overview?tier=75', ['tier']);
selectEngine = () => navigation.push('/overview?engine=all', ['engine']);
selectReference = () => navigation.push('/overview?ref=b300', ['ref']);
+ selectRowScope = () => navigation.push('/overview?compare=30d&rows=changed', ['rows']);
+ selectHardwareRowScope = () => navigation.push('/overview?hwrows=priced', ['hwrows']);
prefetchTier = () => navigation.prefetch('/overview?tier=75', ['tier']);
return (
<>
@@ -105,6 +113,8 @@ afterEach(() => {
selectTier = undefined;
selectEngine = undefined;
selectReference = undefined;
+ selectRowScope = undefined;
+ selectHardwareRowScope = undefined;
prefetchTier = undefined;
vi.unstubAllGlobals();
});
@@ -384,6 +394,32 @@ describe('OverviewNavigationProvider', () => {
expect(readProbe('reference')).toBe('b300');
});
+ // Both row scopes narrow the rows the server sends, so neither may collapse
+ // into the unfiltered payload's cache key the way `ref` deliberately does.
+ it('requests a narrowed matrix for each row scope instead of reusing the full one', () => {
+ deferredFetch();
+
+ renderProvider(pageData(50), '/overview?compare=30d');
+ act(() => selectRowScope?.());
+
+ expect(fetch).toHaveBeenCalledWith('/api/v1/overview?compare=30d&rows=changed', {
+ headers: { Accept: 'application/json' },
+ });
+ expect(readProbe('pending')).toBe('pending');
+ });
+
+ it('requests a narrowed matrix for the hardware row scope', () => {
+ deferredFetch();
+
+ renderProvider(pageData(50), '/overview');
+ act(() => selectHardwareRowScope?.());
+
+ expect(fetch).toHaveBeenCalledWith('/api/v1/overview?hwrows=priced', {
+ headers: { Accept: 'application/json' },
+ });
+ expect(readProbe('pending')).toBe('pending');
+ });
+
it('preserves unknown params and the fragment on the first selection', () => {
deferredFetch();
diff --git a/packages/app/src/components/overview/overview-navigation.tsx b/packages/app/src/components/overview/overview-navigation.tsx
index 4de7b2d5..248408bd 100644
--- a/packages/app/src/components/overview/overview-navigation.tsx
+++ b/packages/app/src/components/overview/overview-navigation.tsx
@@ -21,8 +21,10 @@ import {
type OverviewReferenceHardware,
resolveOverviewComparisonMode,
resolveOverviewEngineScope,
+ resolveOverviewHardwareRowScope,
resolveOverviewModelScope,
resolveOverviewReferenceHardware,
+ resolveOverviewRowScope,
resolveOverviewTier,
} from '@/lib/overview-data';
import {
@@ -39,6 +41,10 @@ import {
* defaults, reordered params, campaign tags, a fragment — collapse to one key,
* so a `ref` change is a guaranteed hit and the CDN sees one entry per data
* state instead of one per link anyone has ever shared.
+ *
+ * Every param the server reads has to appear here. Both row scopes do: each
+ * narrows the rows the response carries, and the dormant one still reaches the
+ * payload so a tab switch can restore the other mode's answer.
*/
function overviewDataKey(href: string): string {
const url = new URL(href, 'https://inferencex.local');
@@ -50,10 +56,12 @@ function overviewDataKey(href: string): string {
resolveOverviewComparisonMode(params.get('compare') ?? undefined),
OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
resolveOverviewModelScope(params.get('models') ?? undefined),
+ resolveOverviewRowScope(params.get('rows') ?? undefined),
+ resolveOverviewHardwareRowScope(params.get('hwrows') ?? undefined),
);
}
-export type OverviewNavControl = 'comparison' | 'engine' | 'models' | 'tier';
+export type OverviewNavControl = 'comparison' | 'engine' | 'hwrows' | 'models' | 'rows' | 'tier';
interface OverviewNavigationValue {
isPending: boolean;
diff --git a/packages/app/src/components/overview/overview-page.tsx b/packages/app/src/components/overview/overview-page.tsx
index a7583068..b6d864b2 100644
--- a/packages/app/src/components/overview/overview-page.tsx
+++ b/packages/app/src/components/overview/overview-page.tsx
@@ -12,8 +12,10 @@ import {
MobileOverviewList,
OverviewComparisonSwitcher,
OverviewEngineScopeSwitcher,
+ OverviewHardwareRowScopeToggle,
OverviewMethodology,
OverviewModelScopeToggle,
+ OverviewRowScopeToggle,
OverviewTierSwitcher,
overviewFormatters,
OVERVIEW_STRINGS,
@@ -25,6 +27,12 @@ import {
useOverviewNavigation,
useOverviewReference,
} from './overview-navigation';
+import {
+ OverviewPresentationProvider,
+ OverviewPresentationSurface,
+ OverviewPresentToggle,
+ useOverviewPresentation,
+} from './overview-presentation';
import { useWideViewport } from './use-wide-viewport';
/** The SemiAnalysis AI Cloud TCO model behind `HW_REGISTRY.costh`. */
@@ -46,12 +54,17 @@ export function OverviewPageContent({ data, locale }: OverviewPageProps) {
data.comparisonMode,
data.referenceHardware,
data.modelScope,
+ data.rowScope,
+ data.hardwareRowScope,
)}
>
{/* Passed as `children`, never rendered inside the provider's own JSX:
that keeps this element's identity stable so a pending-state change
- re-renders the provider without re-rendering the whole matrix. */}
-
+ re-renders the provider without re-rendering the whole matrix. The
+ presentation provider is in the same slot for the same reason. */}
+
+
+
);
}
@@ -87,134 +100,297 @@ function OverviewPageBody({ locale }: { locale: OverviewLocale }) {
// Not `data.referenceHardware`: the reference follows the URL directly, so a
// cached payload built for another reference still renders the right column.
const referenceHardware = useOverviewReference();
- // Both surfaces used to render on every width and hide one with CSS, so every
- // selection built the matrix twice. The Tailwind classes stay — they carry
- // SSR and the pre-hydration frame — and this only drops the unused one after.
- const wide = useWideViewport();
+ const { presenting } = useOverviewPresentation();
const strings = OVERVIEW_STRINGS[locale];
- const formatters = overviewFormatters(locale);
return (
-
-
- {/* Two rows at every width: the title, then the metric it is
+ {/* Held in a stable child slot: swapping the header out for the surface
+ would remount the surface and drop the browser out of fullscreen. The
+ browser already stops painting it, so this only keeps the hidden
+ duplicates of the SLO and engine controls out of the accessibility
+ tree while the surface renders its own. */}
+ {presenting ? null : (
+ /* Tighter than the default card rhythm: every pixel spent here pushes
+ the matrix further below the fold on a laptop viewport. */
+
+
+
+ )}
- {/* Official-only summary; uploaded runs remain in the linked dashboard. */}
- {/* Clipped on phones for the rounded corners; visible from xl so the
- desktop matrix header can stick to the page as it scrolls. */}
-
- {wide === false ? null : (
-
+
+
+
+ );
+}
+
+/**
+ * The strip above the matrix. On the page it is just the view tabs plus the
+ * button that starts a presentation. Presenting turns it into the deck's only
+ * toolbar, so it splits into three columns — what is being measured on the
+ * left, which view on the centre, and the way out on the right — rather than
+ * letting a centred row push the tabs off-centre and read Exit as a third tab.
+ */
+function OverviewControlRow({ locale }: { locale: OverviewLocale }) {
+ const data = useOverviewData();
+ const referenceHardware = useOverviewReference();
+ const { presenting } = useOverviewPresentation();
+ const strings = OVERVIEW_STRINGS[locale];
+
+ const views = (
+
+ );
+
+ if (!presenting) {
+ return (
+
+ {views}
+
+
+ );
+ }
+
+ return (
+
+ {/* The header card carrying the SLO is gone while presenting, so the
+ control comes along here rather than stranding the audience on
+ whichever tier the deck happened to open on. */}
+
+
+
+
{views}
+ {/* The scope filters live under the matrix on the page, where their full
+ sentences fit. Here they ride next to Exit as chips: the left column
+ is already 355px of SLO, and moving the tabs off the matrix centre to
+ make room there would cost more than it buys. */}
+
+ {data.comparisonMode === 'history' ? (
+
- )}
- {wide === true ? null : (
-
)}
-
+
+
+
+ );
+}
+
+/** The half of the page that goes fullscreen: the view tabs and the matrix. */
+function OverviewMatrixSection({ locale }: { locale: OverviewLocale }) {
+ const data = useOverviewData();
+ const referenceHardware = useOverviewReference();
+ const { presenting } = useOverviewPresentation();
+ // Both surfaces used to render on every width and hide one with CSS, so every
+ // selection built the matrix twice. The Tailwind classes stay — they carry
+ // SSR and the pre-hydration frame — and this only drops the unused one after.
+ const wide = useWideViewport();
+ const strings = OVERVIEW_STRINGS[locale];
+ const formatters = overviewFormatters(locale);
+
+ const matrix = (
+
+ );
+
+ return (
+ <>
+
+
+ {/* Official-only summary; uploaded runs remain in the linked dashboard. */}
+ {/* Clipped on phones for the rounded corners; visible from xl so the
+ desktop matrix header can stick to the page as it scrolls. */}
+
+ {/* A deck lays out at a fixed width and is scaled by `zoom`, so the
+ viewport no longer decides which surface fits: the matrix is the
+ slide at every projector size and the phone list would only ever be
+ dead weight behind it. */}
+ {presenting ? (
+ matrix
+ ) : (
+ <>
+ {wide === false ? null : matrix}
+ {wide === true ? null : (
+
+ )}
+
+ {data.comparisonMode === 'history' ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
-
+ >
);
}
diff --git a/packages/app/src/components/overview/overview-presentation.test.tsx b/packages/app/src/components/overview/overview-presentation.test.tsx
new file mode 100644
index 00000000..ef529c62
--- /dev/null
+++ b/packages/app/src/components/overview/overview-presentation.test.tsx
@@ -0,0 +1,205 @@
+// @vitest-environment jsdom
+
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { OverviewPageData } from '@/lib/overview-data';
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+import { OverviewNavigationProvider } from './overview-navigation';
+import {
+ DesktopOverviewMatrix,
+ overviewFormatters,
+ OVERVIEW_STRINGS,
+ type OverviewStrings,
+} from './overview-scorecard';
+import {
+ OverviewPresentationProvider,
+ OverviewPresentationSurface,
+ OverviewPresentToggle,
+} from './overview-presentation';
+
+declare global {
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const strings = {
+ presentEnter: 'Present',
+ presentExit: 'Exit',
+ presentEnterAria: 'Show the matrix full screen',
+ presentExitAria: 'Leave full screen',
+ presentShortcutHint: 'Arrow keys switch views · Esc exits',
+} as unknown as OverviewStrings;
+
+const HISTORY_DATA: OverviewPageData = {
+ models: [],
+ tier: 50,
+ engineScope: 'all',
+ comparisonMode: 'history',
+ referenceHardware: 'b200',
+ modelScope: 'default',
+ rowScope: 'all',
+ hardwareRowScope: 'all',
+ unchangedRowCount: 0,
+ emptyRowCount: 0,
+ historicalWindow: null,
+};
+
+let container: HTMLDivElement;
+let root: Root;
+let fullscreenElement: Element | null;
+let requestFullscreen: ReturnType;
+let exitFullscreen: ReturnType;
+
+function stubFullscreenApi(enabled: boolean) {
+ fullscreenElement = null;
+ const setFullscreenElement = (element: Element | null) => {
+ fullscreenElement = element;
+ document.dispatchEvent(new Event('fullscreenchange'));
+ return Promise.resolve();
+ };
+ requestFullscreen = vi.fn(function requestFullscreenStub(this: Element) {
+ return setFullscreenElement(this);
+ });
+ exitFullscreen = vi.fn(() => setFullscreenElement(null));
+
+ Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: enabled });
+ Object.defineProperty(document, 'fullscreenElement', {
+ configurable: true,
+ get: () => fullscreenElement,
+ });
+ Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+ Object.defineProperty(Element.prototype, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ });
+}
+
+function render() {
+ act(() => {
+ root.render(
+
+
+
+
+
+
+ ,
+ );
+ });
+}
+
+const surface = () =>
+ container.querySelector('[data-testid="overview-presentation-surface"]');
+const toggle = () =>
+ container.querySelector('[data-testid="overview-present-toggle"]');
+
+beforeEach(() => {
+ window.history.replaceState({}, '', '/overview?compare=30d');
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ stubFullscreenApi(true);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+describe('OverviewPresentationSurface', () => {
+ it('hands the surface to the Fullscreen API and mirrors the browser back', () => {
+ render();
+ expect(toggle()?.textContent).toBe('Present');
+ expect(surface()?.dataset.presenting).toBe('false');
+
+ act(() => toggle()?.click());
+ expect(requestFullscreen).toHaveBeenCalledTimes(1);
+ expect(fullscreenElement).toBe(surface());
+ expect(surface()?.dataset.presenting).toBe('true');
+ expect(toggle()?.textContent).toBe('Exit');
+
+ act(() => toggle()?.click());
+ expect(exitFullscreen).toHaveBeenCalledTimes(1);
+ expect(surface()?.dataset.presenting).toBe('false');
+ expect(toggle()?.textContent).toBe('Present');
+ });
+
+ it('follows the browser out of fullscreen when Esc bypasses the button', () => {
+ render();
+ act(() => toggle()?.click());
+ expect(surface()?.dataset.presenting).toBe('true');
+
+ act(() => {
+ fullscreenElement = null;
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+ expect(surface()?.dataset.presenting).toBe('false');
+ });
+
+ it('pages between the two views with the arrow keys, but only while presenting', () => {
+ const requested: string[] = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((input: RequestInfo | URL) => {
+ requested.push(String(input));
+ // Never settles: the assertion only cares that the request went out.
+ return new Promise(() => {});
+ }),
+ );
+ render();
+
+ act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })));
+ expect(requested).toEqual([]);
+
+ act(() => toggle()?.click());
+ act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })));
+
+ expect(requested).toHaveLength(1);
+ // Leaving the history view is the whole point of the keypress.
+ expect(requested[0]).not.toContain('compare=30d');
+ });
+
+ it('stays out of the way on browsers that refuse fullscreen', () => {
+ stubFullscreenApi(false);
+ render();
+ expect(toggle()).toBeNull();
+ expect(surface()).not.toBeNull();
+ });
+});
+
+const matrix = (presenting: boolean) => (
+
+);
+
+describe('DesktopOverviewMatrix', () => {
+ it('drops the viewport gate while presenting so a narrow projector still gets a matrix', () => {
+ const gate = () =>
+ container.querySelector('[data-testid="overview-desktop-matrix"]')?.parentElement?.className;
+
+ act(() => root.render(matrix(false)));
+ expect(gate()).toBe('hidden xl:block');
+
+ // `xl` asks whether the viewport can hold the matrix. That is the right
+ // question on the page and the wrong one on a deck, which lays out at a
+ // fixed width and is scaled by `zoom`: keeping it would blank the slide on
+ // any projector under 1280px, since presenting also drops the phone list.
+ act(() => root.render(matrix(true)));
+ expect(gate()).toBe('block');
+ });
+});
diff --git a/packages/app/src/components/overview/overview-presentation.tsx b/packages/app/src/components/overview/overview-presentation.tsx
new file mode 100644
index 00000000..b62e5d68
--- /dev/null
+++ b/packages/app/src/components/overview/overview-presentation.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import {
+ createContext,
+ type ReactNode,
+ type RefObject,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import type { OverviewComparisonMode } from '@/lib/overview-data';
+import { overviewHref } from '@/lib/overview-links';
+
+import {
+ useOverviewData,
+ useOverviewNavigation,
+ useOverviewReference,
+} from './overview-navigation';
+import type { OverviewLocale, OverviewStrings } from './overview-scorecard';
+
+/**
+ * Width the matrix is laid out at while presenting, before `zoom` magnifies it.
+ * Fixing it keeps the column proportions identical to the on-page matrix, so a
+ * projected slide reads the same as the page the audience visits afterwards.
+ */
+const PRESENTATION_LAYOUT_WIDTH = 1200;
+
+/** Room left around the matrix so it never runs into the bezel. */
+const PRESENTATION_FILL = { width: 0.96, height: 0.94 };
+
+const PRESENTATION_ZOOM_RANGE = { min: 0.3, max: 3 };
+
+interface OverviewPresentationState {
+ presenting: boolean;
+ /** False until mounted, and on browsers that refuse the Fullscreen API. */
+ supported: boolean;
+ toggle: () => void;
+}
+
+interface OverviewPresentationContextValue extends OverviewPresentationState {
+ surfaceRef: RefObject;
+ scalerRef: RefObject;
+}
+
+const OverviewPresentationContext = createContext(null);
+
+function usePresentationContext(): OverviewPresentationContextValue {
+ const value = useContext(OverviewPresentationContext);
+ if (value === null) throw new Error('Presentation controls require OverviewPresentationProvider');
+ return value;
+}
+
+export function useOverviewPresentation(): OverviewPresentationState {
+ return usePresentationContext();
+}
+
+/**
+ * For leaves that only need to adapt if a presentation happens to be running.
+ * Unlike the controls, they are reusable outside the overview page, where "not
+ * presenting" is the honest answer rather than a wiring mistake.
+ */
+export function useIsPresenting(): boolean {
+ return useContext(OverviewPresentationContext)?.presenting ?? false;
+}
+
+/**
+ * Owns the fullscreen state for the whole page, not just the surface: the page
+ * body has to know it is presenting so it can drop the chrome that lives
+ * outside the surface from the DOM rather than leaving it for the browser to
+ * merely stop painting.
+ */
+export function OverviewPresentationProvider({
+ locale,
+ children,
+}: {
+ locale: OverviewLocale;
+ children: ReactNode;
+}) {
+ const data = useOverviewData();
+ // Same reason the matrix reads it here: the reference follows the URL, so a
+ // payload still cached from another reference must not rewrite it.
+ const referenceHardware = useOverviewReference();
+ const { push } = useOverviewNavigation();
+ const surfaceRef = useRef(null);
+ const scalerRef = useRef(null);
+ const [presenting, setPresenting] = useState(false);
+ const [supported, setSupported] = useState(false);
+
+ useEffect(() => setSupported(document.fullscreenEnabled), []);
+
+ useEffect(() => {
+ const syncPresenting = () =>
+ setPresenting(
+ surfaceRef.current !== null && document.fullscreenElement === surfaceRef.current,
+ );
+ document.addEventListener('fullscreenchange', syncPresenting);
+ return () => document.removeEventListener('fullscreenchange', syncPresenting);
+ }, []);
+
+ const toggle = useCallback(() => {
+ const surface = surfaceRef.current;
+ if (surface === null) return;
+ if (document.fullscreenElement === surface) void document.exitFullscreen();
+ else void surface.requestFullscreen().catch(() => setPresenting(false));
+ }, []);
+
+ // Magnify rather than restyle: one `zoom` scales type, padding and rules
+ // together, so the projected matrix cannot drift from the page's own layout.
+ const fit = useCallback(() => {
+ const surface = surfaceRef.current;
+ const scaler = scalerRef.current;
+ if (surface === null || scaler === null) return;
+ scaler.style.removeProperty('zoom');
+ if (!presenting) return;
+
+ const { width, height } = scaler.getBoundingClientRect();
+ if (width === 0 || height === 0) return;
+ const factor = Math.min(
+ (surface.clientWidth * PRESENTATION_FILL.width) / width,
+ (surface.clientHeight * PRESENTATION_FILL.height) / height,
+ );
+ scaler.style.zoom = String(
+ Math.min(Math.max(factor, PRESENTATION_ZOOM_RANGE.min), PRESENTATION_ZOOM_RANGE.max),
+ );
+ }, [presenting]);
+
+ // `data` is the dependency that matters beyond resizing: changing the view or
+ // the SLO mid presentation changes the row count, and the matrix has to be
+ // refitted.
+ useEffect(() => {
+ fit();
+ window.addEventListener('resize', fit);
+ return () => window.removeEventListener('resize', fit);
+ }, [fit, data]);
+
+ useEffect(() => {
+ if (!presenting) return undefined;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
+ // Two views, so either arrow means "the other one" — the audience reads
+ // it as paging through slides.
+ const target: OverviewComparisonMode =
+ data.comparisonMode === 'history' ? 'hardware' : 'history';
+ event.preventDefault();
+ // Only `compare` is merged out of this href, so both row scopes stay on
+ // the URL as they are and there is nothing to pass for them here.
+ push(
+ overviewHref(
+ locale,
+ data.tier,
+ data.engineScope,
+ target,
+ referenceHardware,
+ data.modelScope,
+ ),
+ ['compare'],
+ );
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [presenting, data, referenceHardware, locale, push]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The element handed to the Fullscreen API. Everything outside it stops
+ * rendering while it is fullscreen, which is what strips the page down to the
+ * matrix.
+ *
+ * Switching views or SLO while presenting is safe: `OverviewNavigationProvider`
+ * swaps the data client-side without a route change, so this element is never
+ * unmounted and the browser keeps it fullscreen.
+ */
+export function OverviewPresentationSurface({ children }: { children: ReactNode }) {
+ const { presenting, surfaceRef, scalerRef } = usePresentationContext();
+ return (
+
+ {/* The toolbar needs more air from the matrix once it is the only chrome
+ on a projector than it does as one strip among many on the page. */}
+
+ {children}
+
+
+ );
+}
+
+export function OverviewPresentToggle({ strings }: { strings: OverviewStrings }) {
+ const { presenting, supported, toggle } = useOverviewPresentation();
+ if (!supported) return null;
+ return (
+
+ {presenting ? strings.presentExit : strings.presentEnter}
+
+ );
+}
diff --git a/packages/app/src/components/overview/overview-reference-select.tsx b/packages/app/src/components/overview/overview-reference-select.tsx
index 9acc4b90..0cc2b4c7 100644
--- a/packages/app/src/components/overview/overview-reference-select.tsx
+++ b/packages/app/src/components/overview/overview-reference-select.tsx
@@ -11,6 +11,7 @@ import { track } from '@/lib/analytics';
import type { OverviewReferenceHardware } from '@/lib/overview-data';
import { useOverviewNavigation, useOverviewReference } from './overview-navigation';
+import { useIsPresenting } from './overview-presentation';
interface ReferenceOption {
href: string;
@@ -31,6 +32,11 @@ export function OverviewReferenceSelect({
// instead of silently discarding it. Nothing to prefetch — a reference change
// costs no request.
const value = useOverviewReference();
+ // A portalled menu lands on `document.body`, which is outside the element the
+ // browser is showing fullscreen and outside the `zoom` the matrix is scaled
+ // by. Rendering it in place keeps it both visible and the same size as the
+ // tab that opened it.
+ const presenting = useIsPresenting();
return (
{options.find((option) => option.value === value)?.label}
-
+
{options.map((option) => (
`View details: ${modelLabel} · ${scenarioLabel}`,
@@ -102,6 +112,30 @@ export const OVERVIEW_STRINGS = {
modelScopeNavLabel: 'Inactive models',
modelScopeShow: 'Show deprecated & maintenance-mode models',
modelScopeHide: 'Hide deprecated & maintenance-mode models',
+ rowScopeNavLabel: 'Rows without a 30-day change',
+ rowScopeShow: (count: number) =>
+ `Show ${count} ${count === 1 ? 'row' : 'rows'} with no 30-day change`,
+ rowScopeHide: (count: number) =>
+ `Hide ${count} ${count === 1 ? 'row' : 'rows'} with no 30-day change`,
+ hardwareRowScopeNavLabel: 'Rows with no result',
+ hardwareRowScopeShow: (count: number) =>
+ `Show ${count} ${count === 1 ? 'row' : 'rows'} with no result on any platform`,
+ hardwareRowScopeHide: (count: number) =>
+ `Hide ${count} ${count === 1 ? 'row' : 'rows'} with no result on any platform`,
+ // Short forms of the sentences above, for the presentation toolbar. They
+ // carry the same verb, so they flip with the scope the same way; the
+ // sentence with the count stays on the accessible name.
+ rowScopeChipHide: 'Hide unchanged',
+ rowScopeChipShow: 'Show all rows',
+ hardwareRowScopeChipHide: 'Hide blanks',
+ hardwareRowScopeChipShow: 'Show all rows',
+ modelScopeChipShow: 'Show inactive',
+ modelScopeChipHide: 'Hide inactive',
+ presentEnter: 'Present',
+ presentExit: 'Exit',
+ presentEnterAria: 'Show the matrix full screen',
+ presentExitAria: 'Leave full screen',
+ presentShortcutHint: 'Arrow keys switch views · Esc exits',
categoryBadges: {
maintenance: 'Maintenance',
deprecated: 'Deprecated',
@@ -136,6 +170,10 @@ export const OVERVIEW_STRINGS = {
single_turn_8k1k: '8K/1K',
agentx: '长上下文多轮真实智能体场景(AgentX)',
},
+ scenarioLabelsShort: {
+ single_turn_8k1k: '8K/1K',
+ agentx: 'AgentX',
+ },
detailLink: '查看详情',
detailAria: (modelLabel: string, scenarioLabel: string) =>
`查看详情:${modelLabel} · ${scenarioLabel}`,
@@ -170,6 +208,23 @@ export const OVERVIEW_STRINGS = {
modelScopeNavLabel: '非活跃模型',
modelScopeShow: '显示已弃用与维护模式模型',
modelScopeHide: '隐藏已弃用与维护模式模型',
+ rowScopeNavLabel: '30 天内无变化的行',
+ rowScopeShow: (count: number) => `显示 ${count} 行 30 天内无变化的数据`,
+ rowScopeHide: (count: number) => `隐藏 ${count} 行 30 天内无变化的数据`,
+ hardwareRowScopeNavLabel: '无结果的行',
+ hardwareRowScopeShow: (count: number) => `显示 ${count} 行所有平台均无结果的数据`,
+ hardwareRowScopeHide: (count: number) => `隐藏 ${count} 行所有平台均无结果的数据`,
+ rowScopeChipHide: '隐藏无变化',
+ rowScopeChipShow: '显示全部行',
+ hardwareRowScopeChipHide: '隐藏空行',
+ hardwareRowScopeChipShow: '显示全部行',
+ modelScopeChipShow: '显示停用模型',
+ modelScopeChipHide: '隐藏停用模型',
+ presentEnter: '演示',
+ presentExit: '退出',
+ presentEnterAria: '全屏展示矩阵',
+ presentExitAria: '退出全屏',
+ presentShortcutHint: '方向键切换视图 · Esc 退出',
categoryBadges: {
maintenance: '维护模式',
deprecated: '已弃用',
@@ -647,6 +702,8 @@ function PlatformCell(props: {
function ModelName({ model, strings }: { model: OverviewModelSummary; strings: OverviewStrings }) {
const badge = strings.categoryBadges[model.category];
+ const label = strings.scenarioLabels[model.scenario];
+ const shortLabel = strings.scenarioLabelsShort[model.scenario];
return (
@@ -664,9 +721,17 @@ function ModelName({ model, strings }: { model: OverviewModelSummary; strings: O
- {strings.scenarioLabels[model.scenario]}
+ {shortLabel === label ? (
+ label
+ ) : (
+ <>
+ {label}
+ {shortLabel}
+ >
+ )}
);
@@ -688,11 +753,16 @@ export function DesktopOverviewMatrix({
strings,
comparisonMode,
referenceHardware,
-}: SurfaceProps) {
+ presenting = false,
+}: SurfaceProps & { presenting?: boolean }) {
const platforms = models[0]?.platforms ?? [];
const referenceLabel = overviewHardwareLabel(referenceHardware);
return (
-
+ // The `xl` gate asks whether the viewport can hold the matrix, which stops
+ // being the question once presenting: the deck lays out at a fixed width and
+ // is scaled by `zoom`, so on a projector narrower than 1280px this would
+ // hide the matrix on a slide whose phone list has already been dropped.
+
{comparisonMode === 'history' ? strings.historyCaption : strings.caption}
@@ -740,7 +810,7 @@ export function DesktopOverviewMatrix({
data-scenario={model.scenario}
className="border-b border-border/50 align-top last:border-b-0"
>
-
+
{/* The link lives with the model it drills into, so the matrix
spends no column on a header that is the same every row. */}
@@ -767,7 +837,7 @@ export function DesktopOverviewMatrix({
referenceHardware,
referenceCost,
)}
- className={`px-3 py-4 align-top ${comparisonMode === 'hardware' && platform.hardware === referenceHardware ? 'bg-muted/30' : ''}`}
+ className={`px-3 py-2.5 align-top ${comparisonMode === 'hardware' && platform.hardware === referenceHardware ? 'bg-muted/30' : ''}`}
>
({
value: hardware,
label: overviewHardwareLabel(hardware),
- href: overviewHref(locale, tier, engineScope, 'hardware', hardware, modelScope),
+ href: overviewHref(
+ locale,
+ tier,
+ engineScope,
+ 'hardware',
+ hardware,
+ modelScope,
+ rowScope,
+ hardwareRowScope,
+ ),
}));
// The inactive-only classes live on the inactive branch, not here: Tailwind
// emits `border-transparent` after `border-secondary` at equal specificity,
@@ -1098,7 +1193,16 @@ export function OverviewComparisonSwitcher({
+ {variant === 'toolbar'
+ ? modelScope === 'all'
+ ? strings.modelScopeChipHide
+ : strings.modelScopeChipShow
+ : sentence}
+
+ );
+ if (variant === 'toolbar') return link;
return (
-
- {modelScope === 'all' ? strings.modelScopeHide : strings.modelScopeShow}
-
+ {link}
+
+ );
+}
+
+/**
+ * Opt-in narrowing to the rows that moved in the window, and the way back.
+ * Rendered only when the two scopes would differ, so a fully-comparable window
+ * shows no dead control.
+ */
+export function OverviewRowScopeToggle({
+ rowScope,
+ unchangedRowCount,
+ tier,
+ engineScope,
+ referenceHardware,
+ modelScope,
+ locale,
+ strings,
+ variant = 'section',
+}: {
+ rowScope: OverviewRowScope;
+ unchangedRowCount: number;
+ tier: OverviewTier;
+ engineScope: OverviewEngineScope;
+ referenceHardware: OverviewReferenceHardware;
+ modelScope: OverviewModelScope;
+ locale: OverviewLocale;
+ strings: OverviewStrings;
+ variant?: OverviewScopeToggleVariant;
+}) {
+ if (unchangedRowCount === 0) return null;
+ const target: OverviewRowScope = rowScope === 'all' ? 'changed' : 'all';
+ const sentence =
+ rowScope === 'all'
+ ? strings.rowScopeHide(unchangedRowCount)
+ : strings.rowScopeShow(unchangedRowCount);
+ const link = (
+
+ {variant === 'toolbar'
+ ? rowScope === 'all'
+ ? strings.rowScopeChipHide
+ : strings.rowScopeChipShow
+ : sentence}
+
+ );
+ if (variant === 'toolbar') return link;
+ return (
+
+ {link}
+
+ );
+}
+
+/** The hardware-mode sibling of {@link OverviewRowScopeToggle}. Kept separate
+ * rather than parameterised: the two filters answer different questions, name
+ * different counts, and each mode remembers its own answer. */
+export function OverviewHardwareRowScopeToggle({
+ hardwareRowScope,
+ emptyRowCount,
+ tier,
+ engineScope,
+ referenceHardware,
+ modelScope,
+ locale,
+ strings,
+ variant = 'section',
+}: {
+ hardwareRowScope: OverviewHardwareRowScope;
+ emptyRowCount: number;
+ tier: OverviewTier;
+ engineScope: OverviewEngineScope;
+ referenceHardware: OverviewReferenceHardware;
+ modelScope: OverviewModelScope;
+ locale: OverviewLocale;
+ strings: OverviewStrings;
+ variant?: OverviewScopeToggleVariant;
+}) {
+ if (emptyRowCount === 0) return null;
+ const target: OverviewHardwareRowScope = hardwareRowScope === 'all' ? 'priced' : 'all';
+ const sentence =
+ hardwareRowScope === 'all'
+ ? strings.hardwareRowScopeHide(emptyRowCount)
+ : strings.hardwareRowScopeShow(emptyRowCount);
+ const link = (
+
+ {variant === 'toolbar'
+ ? hardwareRowScope === 'all'
+ ? strings.hardwareRowScopeChipHide
+ : strings.hardwareRowScopeChipShow
+ : sentence}
+
+ );
+ if (variant === 'toolbar') return link;
+ return (
+
+ {link}
);
}
diff --git a/packages/app/src/components/overview/overview-switcher-focus.test.tsx b/packages/app/src/components/overview/overview-switcher-focus.test.tsx
index e4b399d4..b43f997e 100644
--- a/packages/app/src/components/overview/overview-switcher-focus.test.tsx
+++ b/packages/app/src/components/overview/overview-switcher-focus.test.tsx
@@ -32,6 +32,10 @@ function pageData(tier: OverviewTier): OverviewPageData {
comparisonMode: 'hardware',
referenceHardware: 'b200',
modelScope: 'default',
+ rowScope: 'all',
+ hardwareRowScope: 'all',
+ unchangedRowCount: 0,
+ emptyRowCount: 0,
historicalWindow: null,
};
}
@@ -47,6 +51,8 @@ function Body() {
comparisonMode={data.comparisonMode}
referenceHardware={data.referenceHardware}
modelScope={data.modelScope}
+ rowScope={data.rowScope}
+ hardwareRowScope={data.hardwareRowScope}
locale="en"
strings={OVERVIEW_STRINGS.en}
/>
diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts
index a557dd7f..8687d34e 100644
--- a/packages/app/src/lib/api-route-catalog.ts
+++ b/packages/app/src/lib/api-route-catalog.ts
@@ -278,10 +278,10 @@ export const apiRouteCatalog = [
method: 'GET',
classification: 'page-bff',
exclusionReason: {
- en: 'Page-owned BFF aggregation whose tier, comparison, and calculator projections are coupled to the overview UI.',
- zh: '由页面拥有的 BFF 聚合;其档位、比较和计算器投影与概览界面紧密耦合。',
+ en: 'Page-owned BFF aggregation whose tier, comparison, row-scope, and calculator projections are coupled to the overview UI.',
+ zh: '由页面拥有的 BFF 聚合;其档位、比较、行范围和计算器投影与概览界面紧密耦合。',
},
- sourceSha256: '7c9830baae39d533ba7db36d44fe520f89cb36b32ffc54ef29404693d0b6b120',
+ sourceSha256: '499089d01754aa470d0ced953d9818d7d2e620dd3521685099924750249f13e2',
},
{
source: 'src/app/api/v1/reliability/route.ts',
diff --git a/packages/app/src/lib/overview-data.server.ts b/packages/app/src/lib/overview-data.server.ts
index 4b0518af..f28f174c 100644
--- a/packages/app/src/lib/overview-data.server.ts
+++ b/packages/app/src/lib/overview-data.server.ts
@@ -5,10 +5,14 @@ import type { BenchmarkRow } from '@/lib/api';
import { getCachedBenchmarks, getCachedBenchmarksAsOf } from '@/lib/benchmark-data.server';
import type { Model } from '@/lib/data-mappings';
import {
+ applyOverviewHardwareRowScope,
+ applyOverviewRowScope,
assembleOverviewHistoricalPageData,
assembleOverviewPageData,
OVERVIEW_DEFAULT_COMPARISON_MODE,
+ OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
OVERVIEW_DEFAULT_MODEL_SCOPE,
+ OVERVIEW_DEFAULT_ROW_SCOPE,
OVERVIEW_PRIMARY_TIER,
OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
overviewHistoricalWindow,
@@ -16,9 +20,11 @@ import {
overviewSnapshotDate,
type OverviewComparisonMode,
type OverviewEngineScope,
+ type OverviewHardwareRowScope,
type OverviewModelScope,
type OverviewPageData,
type OverviewReferenceHardware,
+ type OverviewRowScope,
type OverviewTier,
} from '@/lib/overview-data';
import { loadFixture } from '@/lib/test-fixtures';
@@ -43,7 +49,13 @@ export async function getOverviewPageData(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): Promise {
+ // Each filter is a no-op outside its own comparison mode, so both run on
+ // every path and the active one is whichever the matrix is actually in.
+ const scopeRows = (data: OverviewPageData): OverviewPageData =>
+ applyOverviewHardwareRowScope(applyOverviewRowScope(data, rowScope), hardwareRowScope);
const models = overviewModelsForScope(modelScope);
// Synthetic rows go through the same assemblers as the live path, so a
// contract drift breaks fixture tests instead of stranding the page.
@@ -51,12 +63,14 @@ export async function getOverviewPageData(
? loadFixture>('overview-rows')
: await loadRowsByModel(models, getCachedBenchmarks);
if (comparisonMode === 'hardware') {
- return assembleOverviewPageData(
- currentRowsByModel,
- tier,
- engineScope,
- referenceHardware,
- modelScope,
+ return scopeRows(
+ assembleOverviewPageData(
+ currentRowsByModel,
+ tier,
+ engineScope,
+ referenceHardware,
+ modelScope,
+ ),
);
}
@@ -75,7 +89,7 @@ export async function getOverviewPageData(
: currentRowsByModel;
const snapshotDate = overviewSnapshotDate(snapshotRows, engineScope);
if (snapshotDate === null) {
- return {
+ return scopeRows({
...assembleOverviewPageData(
currentRowsByModel,
tier,
@@ -84,7 +98,7 @@ export async function getOverviewPageData(
modelScope,
),
comparisonMode: 'history',
- };
+ });
}
const window = overviewHistoricalWindow(snapshotDate);
@@ -98,13 +112,15 @@ export async function getOverviewPageData(
]),
);
- return assembleOverviewHistoricalPageData(
- currentRowsByModel,
- baselineRowsByModel,
- window,
- tier,
- engineScope,
- referenceHardware,
- modelScope,
+ return scopeRows(
+ assembleOverviewHistoricalPageData(
+ currentRowsByModel,
+ baselineRowsByModel,
+ window,
+ tier,
+ engineScope,
+ referenceHardware,
+ modelScope,
+ ),
);
}
diff --git a/packages/app/src/lib/overview-data.test.ts b/packages/app/src/lib/overview-data.test.ts
index 43d3ea64..0b4980e5 100644
--- a/packages/app/src/lib/overview-data.test.ts
+++ b/packages/app/src/lib/overview-data.test.ts
@@ -13,9 +13,15 @@ import {
Precision,
} from './data-mappings';
import {
+ applyOverviewHardwareRowScope,
+ applyOverviewRowScope,
assembleOverviewHistoricalPageData,
assembleOverviewPageData,
buildOverviewModelSummary,
+ OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
+ OVERVIEW_DEFAULT_ROW_SCOPE,
+ overviewRowHasAnyCost,
+ overviewRowHasHistoricalChange,
overviewCostPerMtok,
overviewHistoricalWindow,
overviewScenarioForModel,
@@ -23,8 +29,10 @@ import {
overviewTierEvidenceDate,
resolveOverviewComparisonMode,
resolveOverviewEngineScope,
+ resolveOverviewHardwareRowScope,
resolveOverviewModelScope,
resolveOverviewReferenceHardware,
+ resolveOverviewRowScope,
resolveOverviewTier,
type OverviewModelSummary,
type OverviewPageData,
@@ -1468,3 +1476,181 @@ describe('overview model scope', () => {
expect(page.modelScope).toBe('all');
});
});
+
+describe('overview row scope', () => {
+ const window = {
+ snapshotDate: '2026-08-03',
+ targetDate: '2026-07-04',
+ earliestDate: '2026-06-04',
+ } as const;
+ // Only Qwen3.5 posts rows, so exactly one summary can be comparable and every
+ // other default row is dead weight in the 30-day matrix.
+ const currentRows = {
+ [Model.Qwen3_5]: frontier([12000, 10000, 8000, 6000], {
+ hardware: 'mi355x',
+ framework: 'sglang',
+ date: '2026-08-01',
+ precision: Precision.FP4,
+ }),
+ };
+ const baselineRows = {
+ [Model.Qwen3_5]: frontier([9600, 8000, 6400, 4800], {
+ hardware: 'mi355x',
+ framework: 'sglang',
+ date: '2026-06-20',
+ precision: Precision.FP4,
+ }),
+ };
+
+ function historyPage() {
+ return assembleOverviewHistoricalPageData(currentRows, baselineRows, window, 50, 'community');
+ }
+
+ it("resolves 'changed' as opt-in and defaults everything else to all rows", () => {
+ expect(resolveOverviewRowScope('changed')).toBe('changed');
+ expect(resolveOverviewRowScope(['changed', 'all'])).toBe('changed');
+ expect(resolveOverviewRowScope('all')).toBe('all');
+ expect(resolveOverviewRowScope('moved')).toBe('all');
+ expect(resolveOverviewRowScope('')).toBe('all');
+ expect(resolveOverviewRowScope(undefined)).toBe('all');
+ });
+
+ it('shows every row by default, so no current cost is hidden without asking', () => {
+ const page = historyPage();
+ const scoped = applyOverviewRowScope(page, OVERVIEW_DEFAULT_ROW_SCOPE);
+
+ expect(OVERVIEW_DEFAULT_ROW_SCOPE).toBe('all');
+ expect(scoped.rowScope).toBe('all');
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.unchangedRowCount).toBe(page.models.length - 1);
+ });
+
+ it("keeps only rows with a comparable platform under opt-in scope 'changed'", () => {
+ const page = historyPage();
+ const scoped = applyOverviewRowScope(page, 'changed');
+
+ expect(scoped.rowScope).toBe('changed');
+ expect(scoped.models).toHaveLength(1);
+ expect(scoped.models[0]?.model).toBe(Model.Qwen3_5);
+ expect(scoped.unchangedRowCount).toBe(page.models.length - 1);
+ expect(
+ scoped.models[0]?.platforms.some((p) => p.historicalComparison?.status === 'comparable'),
+ ).toBe(true);
+ });
+
+ it('never drops a row that still prices a platform without a 30-day baseline', () => {
+ const page = historyPage();
+ const dropped = page.models.filter((m) => !overviewRowHasHistoricalChange(m));
+ const stillPriced = dropped.filter((m) => m.platforms.some((p) => p.costPerMtok !== null));
+
+ // The default must keep them; only the opt-in scope may remove them.
+ expect(applyOverviewRowScope(page, 'all').models).toEqual(expect.arrayContaining(stillPriced));
+ for (const summary of stillPriced) {
+ expect(applyOverviewRowScope(page, 'changed').models).not.toContain(summary);
+ }
+ });
+
+ it('filters nothing in hardware mode but still carries the reader’s answer', () => {
+ const page = assembleOverviewPageData(currentRows, 50, 'community');
+ const scoped = applyOverviewRowScope(page, 'changed');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ // Carried, not cleared: the page rebuilds its own URL from this data, so
+ // clearing it here is what dropped the 30-day filter on a tab switch back.
+ expect(scoped.rowScope).toBe('changed');
+ expect(scoped.unchangedRowCount).toBe(0);
+ });
+
+ it('falls back to the full matrix when no row moved, rather than rendering nothing', () => {
+ const page = assembleOverviewHistoricalPageData({}, {}, window, 50, 'community');
+ const scoped = applyOverviewRowScope(page, 'changed');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.rowScope).toBe('all');
+ expect(scoped.unchangedRowCount).toBe(0);
+ });
+});
+
+describe('overview hardware row scope', () => {
+ const window = {
+ snapshotDate: '2026-08-03',
+ targetDate: '2026-07-04',
+ earliestDate: '2026-06-04',
+ } as const;
+ // Only Qwen3.5 posts rows, and only on MI355X. That makes it the row the
+ // filter has to reason about carefully: it prices a platform while the
+ // default B200 reference prices nothing, so it has a cost but no delta.
+ const currentRows = {
+ [Model.Qwen3_5]: frontier([12000, 10000, 8000, 6000], {
+ hardware: 'mi355x',
+ framework: 'sglang',
+ date: '2026-08-01',
+ precision: Precision.FP4,
+ }),
+ };
+
+ function hardwarePage() {
+ return assembleOverviewPageData(currentRows, 50, 'community');
+ }
+
+ it("resolves 'priced' as opt-in and defaults everything else to all rows", () => {
+ expect(resolveOverviewHardwareRowScope('priced')).toBe('priced');
+ expect(resolveOverviewHardwareRowScope(['priced', 'all'])).toBe('priced');
+ expect(resolveOverviewHardwareRowScope('all')).toBe('all');
+ expect(resolveOverviewHardwareRowScope('empty')).toBe('all');
+ expect(resolveOverviewHardwareRowScope('')).toBe('all');
+ expect(resolveOverviewHardwareRowScope(undefined)).toBe('all');
+ });
+
+ it('shows every row by default, so no current cost is hidden without asking', () => {
+ const page = hardwarePage();
+ const scoped = applyOverviewHardwareRowScope(page, OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE);
+
+ expect(OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE).toBe('all');
+ expect(scoped.hardwareRowScope).toBe('all');
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.emptyRowCount).toBe(page.models.filter((m) => !overviewRowHasAnyCost(m)).length);
+ });
+
+ it("keeps only rows pricing a platform under opt-in scope 'priced'", () => {
+ const page = hardwarePage();
+ const priced = page.models.filter(overviewRowHasAnyCost);
+ const scoped = applyOverviewHardwareRowScope(page, 'priced');
+
+ expect(priced.length).toBeGreaterThan(0);
+ expect(priced.length).toBeLessThan(page.models.length);
+ expect(scoped.hardwareRowScope).toBe('priced');
+ expect(scoped.models).toEqual(priced);
+ expect(scoped.emptyRowCount).toBe(page.models.length - priced.length);
+ });
+
+ it('keeps a row priced only on a platform the reference never reached', () => {
+ // The tempting predicate — "has a delta against the reference" — would drop
+ // this row, deleting a cost that appears nowhere else, and would hide a
+ // different set of rows for every reference the reader picks.
+ const scoped = applyOverviewHardwareRowScope(hardwarePage(), 'priced');
+ const kept = scoped.models.filter((m) => m.model === Model.Qwen3_5);
+
+ expect(kept.length).toBeGreaterThan(0);
+ expect(kept.some((m) => m.platforms.some((p) => p.costPerMtok !== null))).toBe(true);
+ expect(kept.every((m) => m.platforms.every((p) => p.costVsReferencePct === null))).toBe(true);
+ });
+
+ it('filters nothing in history mode but still carries the reader’s answer', () => {
+ const page = assembleOverviewHistoricalPageData(currentRows, {}, window, 50, 'community');
+ const scoped = applyOverviewHardwareRowScope(page, 'priced');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.hardwareRowScope).toBe('priced');
+ expect(scoped.emptyRowCount).toBe(0);
+ });
+
+ it('falls back to the full matrix when no row prices anything', () => {
+ const page = assembleOverviewPageData({}, 50, 'community');
+ const scoped = applyOverviewHardwareRowScope(page, 'priced');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.hardwareRowScope).toBe('all');
+ expect(scoped.emptyRowCount).toBe(0);
+ });
+});
diff --git a/packages/app/src/lib/overview-data.ts b/packages/app/src/lib/overview-data.ts
index 1bedd559..d5559020 100644
--- a/packages/app/src/lib/overview-data.ts
+++ b/packages/app/src/lib/overview-data.ts
@@ -36,6 +36,19 @@ export type OverviewComparisonMode = 'hardware' | 'history';
export const OVERVIEW_DEFAULT_COMPARISON_MODE: OverviewComparisonMode = 'hardware';
export type OverviewModelScope = 'default' | 'all';
export const OVERVIEW_DEFAULT_MODEL_SCOPE: OverviewModelScope = 'default';
+/** History mode only: `changed` narrows the matrix to rows that moved in the
+ * window. It is opt-in — the default shows every row, because an unchanged row
+ * still carries current cost the reader came to audit. Ignored in hardware
+ * mode, where every row carries a comparison. */
+export type OverviewRowScope = 'changed' | 'all';
+export const OVERVIEW_DEFAULT_ROW_SCOPE: OverviewRowScope = 'all';
+/** Hardware mode only: `priced` drops rows that quote no platform at all, which
+ * carry neither a cost nor a comparison and exist purely to say "not measured".
+ * Deliberately not "rows without a delta against the reference": that would
+ * delete rows pricing three chips just because the reference happens to miss
+ * this scenario, and the count would swing with the chosen reference. */
+export type OverviewHardwareRowScope = 'priced' | 'all';
+export const OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE: OverviewHardwareRowScope = 'all';
export type OverviewScenario = 'single_turn_8k1k' | 'agentx';
/** Row order within a model: the single-turn workload first, AgentX below it. */
export const OVERVIEW_SCENARIOS = ['single_turn_8k1k', 'agentx'] as const;
@@ -71,6 +84,20 @@ export function resolveOverviewModelScope(
return candidate === 'all' ? 'all' : OVERVIEW_DEFAULT_MODEL_SCOPE;
}
+export function resolveOverviewRowScope(
+ raw: string | readonly string[] | undefined,
+): OverviewRowScope {
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
+ return candidate === 'changed' ? 'changed' : OVERVIEW_DEFAULT_ROW_SCOPE;
+}
+
+export function resolveOverviewHardwareRowScope(
+ raw: string | readonly string[] | undefined,
+): OverviewHardwareRowScope {
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
+ return candidate === 'priced' ? 'priced' : OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE;
+}
+
// Note (wenyao): row order is a contract — defaults, then maintenance, then
// deprecated, each in MODEL_CONFIG declaration order; the overview e2e asserts
// inactive rows always sit below the default rows.
@@ -184,6 +211,15 @@ export interface OverviewPageData {
comparisonMode: OverviewComparisonMode;
referenceHardware: OverviewReferenceHardware;
modelScope: OverviewModelScope;
+ rowScope: OverviewRowScope;
+ hardwareRowScope: OverviewHardwareRowScope;
+ /** Rows with no 30-day change, counted over the full matrix regardless of the
+ * active scope, so the toggle can name the same number in both directions.
+ * Zero outside history mode and whenever filtering would change nothing. */
+ unchangedRowCount: number;
+ /** Rows quoting no platform at all, counted the same way for hardware mode.
+ * Zero outside hardware mode and whenever filtering would change nothing. */
+ emptyRowCount: number;
historicalWindow: OverviewHistoricalWindow | null;
}
@@ -719,10 +755,90 @@ export function assembleOverviewPageData(
comparisonMode: OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware,
modelScope,
+ rowScope: 'all',
+ hardwareRowScope: 'all',
+ unchangedRowCount: 0,
+ emptyRowCount: 0,
historicalWindow: null,
};
}
+/** A row earns its place in the 30-day matrix when at least one platform has a
+ * baseline to compare against. `comparable` is the only status carrying a
+ * `costDeltaPct`, so it is the same predicate the cells render from. */
+export function overviewRowHasHistoricalChange(model: OverviewModelSummary): boolean {
+ return model.platforms.some((platform) => platform.historicalComparison?.status === 'comparable');
+}
+
+/**
+ * Counts the rows that did not move, and narrows the matrix to the ones that
+ * did when the reader opted in.
+ *
+ * Hardware mode passes straight through. So does a window in which nothing is
+ * comparable — filtering to nothing tells the reader less than the unfiltered
+ * matrix, and the empty state is indistinguishable from a data outage.
+ *
+ * Note: an unchanged row is not an empty row. It routinely carries current
+ * costs that exist nowhere else on the page — on the live site Kimi K3's only
+ * row has no 30-day baseline yet still prices three platforms. That is why
+ * narrowing is opt-in rather than the default.
+ */
+export function applyOverviewRowScope(
+ data: OverviewPageData,
+ rowScope: OverviewRowScope,
+): OverviewPageData {
+ // Hardware mode filters on its own terms, but the reader's answer here is
+ // still carried so a tab switch can restore it. Only the count is zeroed:
+ // there is no control to label while this mode is off screen.
+ if (data.comparisonMode !== 'history') {
+ return { ...data, rowScope, unchangedRowCount: 0 };
+ }
+
+ const changed = data.models.filter(overviewRowHasHistoricalChange);
+ const unchangedRowCount = data.models.length - changed.length;
+ if (changed.length === 0 || unchangedRowCount === 0) {
+ return { ...data, rowScope: 'all', unchangedRowCount: 0 };
+ }
+
+ return rowScope === 'changed'
+ ? { ...data, models: changed, rowScope: 'changed', unchangedRowCount }
+ : { ...data, rowScope: 'all', unchangedRowCount };
+}
+
+/** A row earns its place in the hardware matrix as soon as one platform quotes
+ * a cost. One price is still a fact about the row; zero prices is a row of
+ * dashes saying only that nothing was measured. */
+export function overviewRowHasAnyCost(model: OverviewModelSummary): boolean {
+ return model.platforms.some((platform) => platform.costPerMtok !== null);
+}
+
+/**
+ * The hardware-mode counterpart of {@link applyOverviewRowScope}: counts the
+ * rows that price nothing, and drops them when the reader opts in.
+ *
+ * History mode passes straight through, as does a matrix where every row is
+ * empty — filtering to nothing would read as an outage rather than a filter.
+ */
+export function applyOverviewHardwareRowScope(
+ data: OverviewPageData,
+ hardwareRowScope: OverviewHardwareRowScope,
+): OverviewPageData {
+ // Carried rather than cleared, for the same reason as the history scope.
+ if (data.comparisonMode !== 'hardware') {
+ return { ...data, hardwareRowScope, emptyRowCount: 0 };
+ }
+
+ const priced = data.models.filter(overviewRowHasAnyCost);
+ const emptyRowCount = data.models.length - priced.length;
+ if (priced.length === 0 || emptyRowCount === 0) {
+ return { ...data, hardwareRowScope: 'all', emptyRowCount: 0 };
+ }
+
+ return hardwareRowScope === 'priced'
+ ? { ...data, models: priced, hardwareRowScope: 'priced', emptyRowCount }
+ : { ...data, hardwareRowScope: 'all', emptyRowCount };
+}
+
function overviewPlatformKey(
model: OverviewModelSummary,
platform: OverviewPlatformResult,
diff --git a/packages/app/src/lib/overview-links.test.ts b/packages/app/src/lib/overview-links.test.ts
index 63ef4a08..264dfaef 100644
--- a/packages/app/src/lib/overview-links.test.ts
+++ b/packages/app/src/lib/overview-links.test.ts
@@ -363,3 +363,147 @@ describe('overview model scope links', () => {
);
});
});
+
+describe('overview row scope links', () => {
+ it('leaves the canonical 30-day URL untouched and appends rows=changed last', () => {
+ expect(overviewHref('en', 50, 'community', 'history', 'b200', 'default', 'all')).toBe(
+ '/overview?compare=30d',
+ );
+ expect(overviewHref('en', 50, 'community', 'history', 'b200', 'default', 'changed')).toBe(
+ '/overview?compare=30d&rows=changed',
+ );
+ expect(overviewHref('en', 100, 'all', 'history', 'b300', 'all', 'changed')).toBe(
+ '/overview?tier=100&engine=all&ref=b300&compare=30d&models=all&rows=changed',
+ );
+ expect(overviewHref('zh', 50, 'community', 'history', 'b200', 'default', 'changed')).toBe(
+ '/zh/overview?compare=30d&rows=changed',
+ );
+ });
+
+ it('keeps the row scope on the URL in hardware mode, where it is dormant', () => {
+ // The 30-day tab is off screen, so the key changes nothing on the rendered
+ // matrix. It is still the only record of that tab's answer, and dropping it
+ // here is what used to lose the filter on refresh or a shared link.
+ expect(overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'changed')).toBe(
+ '/overview?rows=changed',
+ );
+ expect(overviewHref('en', 75, 'community', 'hardware', 'b200', 'all', 'changed')).toBe(
+ '/overview?tier=75&models=all&rows=changed',
+ );
+ });
+
+ it('preserves the row scope when changing tiers and engine scope', () => {
+ expect(overviewTierHref('en', 75, 'community', 'history', 'b200', 'default', 'changed')).toBe(
+ '/overview?tier=75&compare=30d&rows=changed',
+ );
+ expect(overviewEngineScopeHref('en', 'all', 50, 'history', 'b200', 'default', 'changed')).toBe(
+ '/overview?engine=all&compare=30d&rows=changed',
+ );
+ });
+
+ it('merges the rows control into pending overview URLs', () => {
+ const narrowed = mergeOverviewControlHref(
+ '/overview?compare=30d',
+ '/overview?compare=30d&rows=changed',
+ ['rows'],
+ );
+ expect(narrowed).toBe('/overview?compare=30d&rows=changed');
+ expect(mergeOverviewControlHref(narrowed, '/overview?tier=75', ['tier'])).toBe(
+ '/overview?tier=75&compare=30d&rows=changed',
+ );
+ expect(mergeOverviewControlHref(narrowed, '/overview?compare=30d', ['rows'])).toBe(
+ '/overview?compare=30d',
+ );
+ });
+});
+
+describe('overview hardware row scope links', () => {
+ it('leaves the canonical URL untouched and appends hwrows=priced last', () => {
+ expect(overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'all', 'all')).toBe(
+ '/overview',
+ );
+ expect(
+ overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/overview?hwrows=priced');
+ expect(overviewHref('en', 100, 'all', 'hardware', 'b300', 'all', 'all', 'priced')).toBe(
+ '/overview?tier=100&engine=all&ref=b300&models=all&hwrows=priced',
+ );
+ expect(
+ overviewHref('zh', 50, 'community', 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/zh/overview?hwrows=priced');
+ });
+
+ it('keeps the hardware row scope on the URL in history mode, where it is dormant', () => {
+ expect(overviewHref('en', 50, 'community', 'history', 'b200', 'default', 'all', 'priced')).toBe(
+ '/overview?compare=30d&hwrows=priced',
+ );
+ });
+
+ it('writes both row scopes at once, so a URL rebuilt from page data keeps them', () => {
+ // Every href the page builds comes from the current page data, including
+ // the one the client router starts from on first load. Emitting only the
+ // active mode's key there erased the other tab's filter on refresh and on
+ // any shared link carrying both.
+ expect(
+ overviewHref('en', 50, 'community', 'history', 'b200', 'default', 'changed', 'priced'),
+ ).toBe('/overview?compare=30d&rows=changed&hwrows=priced');
+ expect(
+ overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'changed', 'priced'),
+ ).toBe('/overview?rows=changed&hwrows=priced');
+ });
+
+ it('preserves the hardware row scope when changing tiers and engine scope', () => {
+ expect(
+ overviewTierHref('en', 75, 'community', 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/overview?tier=75&hwrows=priced');
+ expect(
+ overviewEngineScopeHref('en', 'all', 50, 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/overview?engine=all&hwrows=priced');
+ });
+
+ it('lets each mode keep its own filter across a tab switch', () => {
+ // The comparison switcher owns only `compare`, so the scope belonging to the
+ // mode being left stays in the URL and is waiting when the reader returns.
+ const hardware = mergeOverviewControlHref('/overview', '/overview?hwrows=priced', ['hwrows']);
+ expect(hardware).toBe('/overview?hwrows=priced');
+
+ const history = mergeOverviewControlHref(hardware, '/overview?compare=30d', ['compare']);
+ expect(history).toBe('/overview?compare=30d&hwrows=priced');
+
+ const narrowed = mergeOverviewControlHref(history, '/overview?compare=30d&rows=changed', [
+ 'rows',
+ ]);
+ expect(narrowed).toBe('/overview?compare=30d&rows=changed&hwrows=priced');
+
+ expect(mergeOverviewControlHref(narrowed, '/overview', ['compare'])).toBe(
+ '/overview?rows=changed&hwrows=priced',
+ );
+ });
+
+ it('lets the models control reset both scopes without clearing the dormant one', () => {
+ // The models toggle owns `models` plus both row keys, because revealing
+ // inactive models resets them. A merged key the target href does not carry
+ // is deleted, so hiding — which resets nothing — has to restate both.
+ const current = '/overview?compare=30d&models=all&rows=changed&hwrows=priced';
+ const keys = ['models', 'rows', 'hwrows'] as const;
+
+ const hiding = overviewHref(
+ 'en',
+ 50,
+ 'community',
+ 'history',
+ 'b200',
+ 'default',
+ 'changed',
+ 'priced',
+ );
+ expect(mergeOverviewControlHref(current, hiding, keys)).toBe(
+ '/overview?compare=30d&rows=changed&hwrows=priced',
+ );
+
+ const revealing = overviewHref('en', 50, 'community', 'history', 'b200', 'all', 'all', 'all');
+ expect(mergeOverviewControlHref(current, revealing, keys)).toBe(
+ '/overview?compare=30d&models=all',
+ );
+ });
+});
diff --git a/packages/app/src/lib/overview-links.ts b/packages/app/src/lib/overview-links.ts
index d8fa4eae..00600191 100644
--- a/packages/app/src/lib/overview-links.ts
+++ b/packages/app/src/lib/overview-links.ts
@@ -1,19 +1,30 @@
import { runIdFromRunUrl } from './known-issues';
import {
OVERVIEW_DEFAULT_COMPARISON_MODE,
+ OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
OVERVIEW_DEFAULT_MODEL_SCOPE,
OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
+ OVERVIEW_DEFAULT_ROW_SCOPE,
OVERVIEW_PRIMARY_TIER,
type OverviewComparisonMode,
type OverviewConfigView,
type OverviewEngineScope,
+ type OverviewHardwareRowScope,
type OverviewModelScope,
type OverviewModelSummary,
type OverviewReferenceHardware,
+ type OverviewRowScope,
type OverviewTier,
} from './overview-data';
-export type OverviewSearchKey = 'tier' | 'engine' | 'ref' | 'compare' | 'models';
+export type OverviewSearchKey =
+ | 'tier'
+ | 'engine'
+ | 'ref'
+ | 'compare'
+ | 'models'
+ | 'rows'
+ | 'hwrows';
export const OVERVIEW_SEARCH_ORDER: readonly OverviewSearchKey[] = [
'tier',
@@ -21,6 +32,8 @@ export const OVERVIEW_SEARCH_ORDER: readonly OverviewSearchKey[] = [
'ref',
'compare',
'models',
+ 'rows',
+ 'hwrows',
];
/** Params the client resolves without asking the server. `ref` only picks which
@@ -196,6 +209,8 @@ export function overviewHref(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): string {
const base = locale === 'zh' ? '/zh/overview' : '/overview';
const query = new URLSearchParams();
@@ -206,6 +221,17 @@ export function overviewHref(
}
if (comparisonMode === 'history') query.set('compare', '30d');
if (modelScope !== OVERVIEW_DEFAULT_MODEL_SCOPE) query.set('models', modelScope);
+ // Each mode filters rows on its own terms and carries its own key. Both are
+ // written whenever they are set, including the one whose mode is off screen:
+ // that dormant key is the only record of the other tab's answer, and a URL
+ // rebuilt from page data — on first load, on refresh, or from a shared link —
+ // has nothing else to restore it from.
+ if (rowScope !== OVERVIEW_DEFAULT_ROW_SCOPE) query.set('rows', rowScope);
+ if (hardwareRowScope !== OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE) {
+ query.set('hwrows', hardwareRowScope);
+ }
+ // `rows=changed` and `hwrows=priced` are the only values ever emitted; `all`
+ // is the default and stays out of the URL so the canonical link is unchanged.
const search = query.toString();
return search === '' ? base : `${base}?${search}`;
}
@@ -218,8 +244,19 @@ export function overviewTierHref(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): string {
- return overviewHref(locale, tier, engineScope, comparisonMode, referenceHardware, modelScope);
+ return overviewHref(
+ locale,
+ tier,
+ engineScope,
+ comparisonMode,
+ referenceHardware,
+ modelScope,
+ rowScope,
+ hardwareRowScope,
+ );
}
/** Engine-scope switch preserving the active service tier. */
@@ -230,6 +267,17 @@ export function overviewEngineScopeHref(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): string {
- return overviewHref(locale, tier, engineScope, comparisonMode, referenceHardware, modelScope);
+ return overviewHref(
+ locale,
+ tier,
+ engineScope,
+ comparisonMode,
+ referenceHardware,
+ modelScope,
+ rowScope,
+ hardwareRowScope,
+ );
}