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. */ + +
+ {/* Two rows at every width: the title, then the metric it is measured in and where that measure comes from. */} -
-

{strings.title}

- {/* Metric, direction and provenance read as one line: the numbers +
+

{strings.title}

+ {/* Metric, direction and provenance read as one line: the numbers and the model they are priced from belong together. */} -

- - {strings.scopeMetric} - {' '} - {' '} - - {strings.scopeDirection} - {' '} - {' '} - - {strings.sourcePrefix} - - {strings.sourceLinkText} - - - -

-
-
- - -
-
-
- - + {strings.scopeMetric} + {' '} + {' '} + + {strings.scopeDirection} + {' '} + {' '} + + {strings.sourcePrefix} + + {strings.sourceLinkText} + + + +

+ +
+ + +
+
+
+ )} - {/* 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 ( + + ); +} 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 (